deriva-dev 0.8.2__tar.gz → 0.8.3__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.3}/PKG-INFO +2 -2
  2. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/pyproject.toml +2 -2
  3. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/pyproject.toml.orig +2 -2
  4. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/benchmarking.py +45 -9
  5. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/cli.py +21 -11
  6. deriva_dev-0.8.3/src/deriva/bench/gate.py +305 -0
  7. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/reference_eval.py +60 -4
  8. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/router.py +3 -2
  9. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/session_metrics.py +74 -6
  10. deriva_dev-0.8.2/src/deriva/bench/gate.py +0 -216
  11. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/LICENSE +0 -0
  12. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/README.md +0 -0
  13. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/__init__.py +0 -0
  14. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/analysis.py +0 -0
  15. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/baseline_b1.py +0 -0
  16. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/benchmark_views.py +0 -0
  17. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/config_deviation.py +0 -0
  18. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/conventions_report.py +0 -0
  19. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/data/conventions.json +0 -0
  20. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/session.py +0 -0
  21. {deriva_dev-0.8.2 → deriva_dev-0.8.3}/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.3
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.3
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.3"
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.3"]
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.3"
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.3"]
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
 
@@ -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),
@@ -2176,13 +2200,25 @@ def model_quality_for_session(
2176
2200
  row: dict[str, Any] = {"repository": repo, "model": model, "run": int(run), **compute_model_quality(elements, relationships).to_dict()}
2177
2201
  snapshot_path = path.with_suffix(".json")
2178
2202
  try:
2179
- row["solve"] = json.loads(snapshot_path.read_text(encoding="utf-8")).get("solve") if snapshot_path.is_file() else None
2203
+ snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) if snapshot_path.is_file() else {}
2180
2204
  except OSError, ValueError:
2181
- row["solve"] = None
2205
+ snapshot = {}
2206
+ row["solve"] = snapshot.get("solve")
2182
2207
  row["reference"] = None
2183
2208
  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]
2209
+ # Name + type, the user's accepted names and structural refs (sources and lineage from the run's
2210
+ # snapshot: the XML carries none) and derivable labels: the gate's matching (golden_mapping)
2211
+ structure = {str(e.get("identifier")): e for e in snapshot.get("elements", []) if isinstance(e, dict)}
2212
+ derived = [
2213
+ {
2214
+ "id": e.identifier,
2215
+ "name": e.name,
2216
+ "type": e.element_type,
2217
+ "source": structure.get(e.identifier, {}).get("source"),
2218
+ "lineage": structure.get(e.identifier, {}).get("lineage") or [],
2219
+ }
2220
+ for e in elements
2221
+ ]
2186
2222
  aliases, derivable = session_metrics.manual_inputs(reference_models[repo])
2187
2223
  row["reference"] = session_metrics.golden_comparison(derived, reference, aliases=aliases, derivable=derivable)
2188
2224
  rows.append(row)
@@ -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)
@@ -383,10 +383,10 @@ def benchmark_gate(
383
383
  reference: Annotated[str | None, typer.Option("--reference", help="Golden model folder (default: workspace/reference)")] = None,
384
384
  benchmarks: Annotated[str | None, typer.Option("--benchmarks", help="Benchmark folder (default: workspace/benchmarks)")] = None,
385
385
  ) -> None:
386
- """Recall, precision and consistency by id per repository, and the verdict of a candidate against a baseline.
386
+ """Element and relationship recall and precision and consistency by id per repository, and the verdict of a candidate.
387
387
 
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.
388
+ Accepted when one of the four measures rises by 1 point or more on at least two thirds of the measured repositories,
389
+ none of them drops by more than 2 points anywhere, and element and relationship consistency by id stay at 95% or more.
390
390
  """
391
391
  from deriva.bench import gate
392
392
 
@@ -399,17 +399,27 @@ def benchmark_gate(
399
399
  typer.echo(f"Error: no benchmark session {', '.join(missing)} in {root}", err=True)
400
400
  raise typer.Exit(1)
401
401
  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}
402
+ measures = {
403
+ 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}
404
+ for level in gate.GATE_LEVELS
405
+ }
403
406
  except ValueError as exc:
404
407
  typer.echo(f"Error: {exc}", err=True)
405
408
  raise typer.Exit(1) from exc
406
409
  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"])
410
+ payload: dict = {"sessions": sides, "levels": {}}
411
+ for level, by_side in measures.items():
412
+ lines += [f"## {level.capitalize()} level", ""]
413
+ lines += gate.table_markdown(by_side["baseline"], f"Baseline ({', '.join(sides['baseline'])})")
414
+ payload["levels"][level] = {"baseline": by_side["baseline"]}
415
+ if "candidate" in by_side:
416
+ lines += gate.table_markdown(by_side["candidate"], f"Candidate ({', '.join(sides['candidate'])})")
417
+ payload["levels"][level] |= {"candidate": by_side["candidate"], "verdict": gate.verdict(by_side["baseline"], by_side["candidate"])}
418
+ lines += gate.verdict_markdown(payload["levels"][level]["verdict"])
419
+ if sides["candidate"]:
420
+ result, voting = gate.overall_verdict({level: entry["verdict"] for level, entry in payload["levels"].items()})
421
+ payload["verdict"] = {"verdict": result, "levels": voting}
422
+ lines += [f"**Overall: {result}** (levels that voted: {', '.join(voting) or 'none'})", ""]
413
423
  for line in lines:
414
424
  typer.echo(line)
415
425
  name = "_".join(sides["baseline"]) + (f"_vs_{'_'.join(sides['candidate'])}" if sides["candidate"] else "")
@@ -0,0 +1,305 @@
1
+ """The gate for a change: recall, precision and consistency per repository, a candidate against a baseline.
2
+
3
+ Per repository (read-only, from the sessions' run snapshots):
4
+
5
+ - **A development reference** (``<goldens>/<name>.json``, the reference format of ``reference_eval``): element and
6
+ relationship recall and precision as ``reference_eval`` counts them, the mean over the runs.
7
+ - **A golden model** (``<reference>/<repository>.archimate``): recall on the derivable subset the user labelled
8
+ (``<repository>.derivable.json``) with the user's accepted names (``<repository>.matches.json``), and precision.
9
+ A golden relationship is found when a derived relationship of its type and direction joins the derived elements
10
+ matched to its two ends; it is in the subset when both ends are. Without the user's files the ``.template.json``
11
+ versions are read and the result is provisional: the subset is then the elements of the derived types and the
12
+ relationships between two of them.
13
+ - **Consistency by id**: the share of elements and of relationships in every run (``session_metrics``).
14
+
15
+ A repository without either reference keeps its consistency and is left out of the count. The verdict: the change is
16
+ accepted when one of the four measures (element or relationship recall or precision) rises by at least the given
17
+ points on enough of the measured repositories, none of the four drops by more than the allowed points anywhere, and
18
+ consistency stays at the minimum everywhere.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from deriva.bench import reference_eval, session_metrics
30
+ from deriva.modules.analysis.semantic_matching import parse_archi_xml, parse_exchange_format_xml
31
+
32
+ MEASURES = ("recall", "precision", "relationship_recall", "relationship_precision")
33
+ GATE_LEVELS = ("enterprise", "solution", "union") # Each is measured and judged; levels without a measure do not vote
34
+ LABELS = {"recall": "recall", "precision": "precision", "relationship_recall": "relationship recall", "relationship_precision": "relationship precision"}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class GateRules:
39
+ """The acceptance rule."""
40
+
41
+ better_share: float = 2 / 3 # Of the measured repositories, at least this share better on one of the measures (6 of 9)
42
+ min_rise: float = 1.0 # Points a measure must rise to count as better (a smaller move holds)
43
+ max_drop: float = 2.0 # Points any measure may drop on any repository
44
+ min_consistency: float = 0.95 # Element and relationship consistency by id on every repository
45
+
46
+
47
+ def _user_file(folder: Path, repository: str, kind: str) -> tuple[dict[str, Any], bool]:
48
+ """The user's file (matches or derivable), else its template; True when the template was read."""
49
+ for name, template in ((f"{repository}.{kind}.json", False), (f"{repository}.{kind}.template.json", True)):
50
+ path = folder / name
51
+ if path.is_file():
52
+ try:
53
+ return json.loads(path.read_text(encoding="utf-8")), template
54
+ except ValueError:
55
+ return {}, True
56
+ return {}, True
57
+
58
+
59
+ def _golden_model(path: Path) -> tuple[list[Any], list[Any]]:
60
+ elements, relationships = parse_archi_xml(path)
61
+ return (elements, relationships) if elements else parse_exchange_format_xml(path)
62
+
63
+
64
+ def _mean(values: list[float | None]) -> float | None:
65
+ known = [v for v in values if v is not None]
66
+ return round(sum(known) / len(known), 3) if known else None
67
+
68
+
69
+ def golden_relationships(
70
+ derived: list[dict[str, Any]],
71
+ relationships: list[dict[str, Any]],
72
+ reference: list[Any],
73
+ golden: list[Any],
74
+ aliases: dict[str, Any],
75
+ scope: set[str],
76
+ ) -> dict[str, Any]:
77
+ """Relationship recall over the golden relationships with both ends in ``scope``, and precision over all derived ones.
78
+
79
+ A derived element maps to golden elements as the element scores map it (``session_metrics.golden_mapping``: by
80
+ name, by the user's accepted names, then by structural refs); a derived relationship is right when a golden
81
+ relationship of its type joins the golden elements of its source and target, in that direction.
82
+ """
83
+ to_golden = session_metrics.golden_mapping(derived, reference, aliases)
84
+ wanted = {(g.source, g.relationship_type, g.target) for g in golden}
85
+ found: set[tuple[str, str, str]] = set()
86
+ correct = 0
87
+ for rel in relationships:
88
+ kind = str(rel["type"]).removeprefix("Model:")
89
+ pairs = {(a, kind, b) for a in to_golden.get(rel["source"], ()) for b in to_golden.get(rel["target"], ())} & wanted
90
+ found |= pairs
91
+ correct += bool(pairs)
92
+ required = [g for g in golden if g.source in scope and g.target in scope]
93
+ hits = sum(1 for g in required if (g.source, g.relationship_type, g.target) in found)
94
+ return {
95
+ "required": len(required),
96
+ "matched": hits,
97
+ "recall": round(hits / len(required), 3) if required else None,
98
+ "derived": len(relationships),
99
+ "correct": correct,
100
+ "precision": round(correct / len(relationships), 3) if relationships else None,
101
+ }
102
+
103
+
104
+ def _golden_row(path: Path, reference_dir: Path, repo: str, snapshots: list[dict[str, Any]]) -> dict[str, Any]:
105
+ reference, golden_rels = _golden_model(path)
106
+ aliases, alias_template = _user_file(reference_dir, repo, "matches")
107
+ derivable, derivable_template = _user_file(reference_dir, repo, "derivable")
108
+ provisional = alias_template or derivable_template
109
+ if provisional:
110
+ scope = {r.identifier for r in reference if r.element_type in session_metrics.DERIVED_TYPES}
111
+ else:
112
+ scope = {r.identifier for r in reference if isinstance(derivable.get(r.identifier), dict) and derivable[r.identifier].get("derivable")}
113
+ elements, relationships = [], []
114
+ for snapshot in snapshots:
115
+ derived = [
116
+ {"id": e["identifier"], "name": e.get("name"), "type": str(e["type"]).removeprefix("Model:"), "source": e.get("source"), "lineage": e.get("lineage") or []}
117
+ for e in snapshot.get("elements", [])
118
+ ]
119
+ elements.append(session_metrics.golden_comparison(derived, reference, aliases=aliases, derivable=derivable))
120
+ relationships.append(golden_relationships(derived, snapshot.get("relationships", []), reference, golden_rels, aliases, scope))
121
+ recall_key = "derivable_types_recall" if provisional else "derivable_recall"
122
+ return {
123
+ "source": "golden",
124
+ "provisional": provisional,
125
+ "recall": _mean([r[recall_key] for r in elements]),
126
+ "precision": _mean([r["precision"] for r in elements]),
127
+ "relationship_recall": _mean([r["recall"] for r in relationships]),
128
+ "relationship_precision": _mean([r["precision"] for r in relationships]),
129
+ }
130
+
131
+
132
+ def measure(sessions: list[Path], goldens_dir: Path, reference_dir: Path, model: str | None = None, *, level: str | None = None) -> dict[str, dict[str, Any]]:
133
+ """Per repository of the sessions: its model, runs, source of the measure, the four measures and consistency by id.
134
+
135
+ ``level`` (``enterprise``, ``solution`` or ``union``) measures one abstraction level; ``None`` measures everything,
136
+ as before levels existed. A reference with no element at the level has no measure there. A reference without level
137
+ tags, and a golden model (none has levels yet), is measured on the union only: untagged, every element would count
138
+ as enterprise and the solution elements a run derives would show as missing there (``note``: no level tags).
139
+ """
140
+ sessions = [Path(s) for s in sessions]
141
+ runs: dict[str, dict[str, list[dict[str, Any]]]] = {}
142
+ for session in sessions:
143
+ for repo, run_model, _, snapshot in session_metrics.run_snapshots(session):
144
+ runs.setdefault(repo, {}).setdefault(run_model, []).append(snapshot)
145
+ references = reference_eval.load_references(goldens_dir) if Path(goldens_dir).is_dir() else {}
146
+ consistency = {(r["repository"], r["model"]): r["identities"]["id"] for r in session_metrics.consistency_rows(sessions, level=level)}
147
+
148
+ out: dict[str, dict[str, Any]] = {}
149
+ for repo, by_model in sorted(runs.items()):
150
+ if model is not None:
151
+ snapshots = by_model.get(model, [])
152
+ if not snapshots:
153
+ continue
154
+ chosen = model
155
+ elif len(by_model) > 1:
156
+ raise ValueError(f"{repo} has runs of several models ({', '.join(sorted(by_model))}): name one with --model")
157
+ else:
158
+ ((chosen, snapshots),) = by_model.items()
159
+ row: dict[str, Any] = {"repository": repo, "model": chosen, "runs": len(snapshots), "source": None, "provisional": False, "note": None} | dict.fromkeys(MEASURES)
160
+ golden = Path(reference_dir) / f"{repo}.archimate"
161
+ single = level not in (None, "union")
162
+ has_level = not single or (repo in references and any(level in e.levels for e in references[repo].elements))
163
+ if single and ((repo in references and not references[repo].tagged) or (repo not in references and golden.is_file())):
164
+ row["note"] = "no level tags" # measured on the union only
165
+ elif repo in references and not has_level:
166
+ pass # no reference at this level
167
+ elif repo in references:
168
+ results = [reference_eval.evaluate(references[repo], s, level=level) for s in snapshots]
169
+ row |= {
170
+ "source": "reference",
171
+ "recall": _mean([r["element_recall"] for r in results]),
172
+ "precision": _mean([r["element_precision"] for r in results]),
173
+ "relationship_recall": _mean([r["relationship_recall"] for r in results]),
174
+ "relationship_precision": _mean([r["relationship_totals"]["precision"] for r in results]),
175
+ }
176
+ elif golden.is_file():
177
+ row |= _golden_row(golden, Path(reference_dir), repo, snapshots)
178
+ ids = consistency.get((repo, chosen))
179
+ row["consistency"] = {"elements": ids["elements"]["cons"], "relationships": ids["relationships"]["cons"]} if ids else None
180
+ out[repo] = row
181
+ return out
182
+
183
+
184
+ def _points(before: float | None, after: float | None) -> float | None:
185
+ return None if before is None or after is None else round((after - before) * 100, 1)
186
+
187
+
188
+ def _lowest(row: dict[str, Any]) -> float | None:
189
+ values = [v for v in (row.get("consistency") or {}).values() if v is not None]
190
+ return min(values) if values else None
191
+
192
+
193
+ def verdict(baseline: dict[str, dict[str, Any]], candidate: dict[str, dict[str, Any]], rules: GateRules | None = None) -> dict[str, Any]:
194
+ """The candidate against the baseline, per repository and overall (PASS or FAIL, with the reasons)."""
195
+ rules = rules or GateRules()
196
+ rows, failures, unmeasured = [], [], []
197
+ for repo in sorted(set(baseline) | set(candidate)):
198
+ before, after = baseline.get(repo), candidate.get(repo)
199
+ measured = bool(before and after and before["recall"] is not None and after["recall"] is not None)
200
+ row: dict[str, Any] = {"repository": repo, "measured": measured, "baseline": before, "candidate": after, "better": False}
201
+ if measured and before and after:
202
+ row["points"] = {m: _points(before.get(m), after.get(m)) for m in MEASURES}
203
+ changes = {m: p for m, p in row["points"].items() if p is not None}
204
+ row["better"] = any(p >= rules.min_rise for p in changes.values())
205
+ row["drop"] = max([0.0] + [-p for p in changes.values()])
206
+ for m, p in changes.items():
207
+ if -p > rules.max_drop:
208
+ failures.append(f"{repo} drops {-p:.1f} points on {LABELS[m]} (at most {rules.max_drop:.1f})")
209
+ else:
210
+ unmeasured.append(repo)
211
+ lowest = _lowest(after) if after else None
212
+ row["consistency"] = lowest
213
+ if lowest is not None and lowest < rules.min_consistency:
214
+ failures.append(f"{repo} consistency {lowest:.1%} (at least {rules.min_consistency:.0%})")
215
+ rows.append(row)
216
+ measured_rows = [r for r in rows if r["measured"]]
217
+ better = sum(1 for r in measured_rows if r["better"])
218
+ needed = math.ceil(rules.better_share * len(measured_rows) - 1e-9)
219
+ held: list[str] = [] # Too few better, nothing failing: the level holds (HELD), it does not fail
220
+ if not measured_rows:
221
+ failures.append("no repository has a measure in both the baseline and the candidate")
222
+ elif better < needed:
223
+ held.append(f"{better} of {len(measured_rows)} measured repositories better (at least {needed})")
224
+ consistencies = [r["consistency"] for r in rows if r["consistency"] is not None]
225
+ return {
226
+ "rows": rows,
227
+ "measured": len(measured_rows),
228
+ "better": better,
229
+ "needed": needed,
230
+ "max_drop": max((r["drop"] for r in measured_rows), default=0.0),
231
+ "min_consistency": min(consistencies) if consistencies else None,
232
+ "unmeasured": unmeasured,
233
+ "failures": failures,
234
+ "held": held,
235
+ "verdict": "FAIL" if failures else ("HELD" if held else "PASS"),
236
+ "rules": {"better_share": rules.better_share, "min_rise": rules.min_rise, "max_drop": rules.max_drop, "min_consistency": rules.min_consistency},
237
+ }
238
+
239
+
240
+ def overall_verdict(by_level: dict[str, dict[str, Any]]) -> tuple[str, list[str]]:
241
+ """The levels with a measured repository vote: FAIL when one fails, PASS when none fails and at least one passes
242
+ (a level the change only holds does not block one it improves), else HELD. Returns the verdict and the voters."""
243
+ voting = [level for level, result in by_level.items() if result.get("measured")]
244
+ verdicts = {by_level[level]["verdict"] for level in voting}
245
+ if not voting or "FAIL" in verdicts:
246
+ return "FAIL", voting
247
+ return ("PASS" if "PASS" in verdicts else "HELD"), voting
248
+
249
+
250
+ def _pct(value: float | None) -> str:
251
+ return "-" if value is None else f"{value:.1%}"
252
+
253
+
254
+ def _source(row: dict[str, Any] | None) -> str:
255
+ if not row or row["source"] is None:
256
+ return (row or {}).get("note") or "no reference"
257
+ return f"{row['source']} (provisional)" if row["provisional"] else row["source"]
258
+
259
+
260
+ def table_markdown(measures: dict[str, dict[str, Any]], title: str) -> list[str]:
261
+ """One row per repository: source, runs, the four measures, consistency by id."""
262
+ lines = [
263
+ f"## {title}",
264
+ "",
265
+ "| Repository | Measure | Runs | Recall | Precision | Rel. recall | Rel. precision | Elements by id | Relationships by id |",
266
+ "|---|---|---|---|---|---|---|---|---|",
267
+ ]
268
+ for repo, row in sorted(measures.items()):
269
+ cons = row.get("consistency") or {}
270
+ values = " | ".join(_pct(row.get(m)) for m in MEASURES)
271
+ lines.append(f"| {repo} | {_source(row)} | {row['runs']} | {values} | {_pct(cons.get('elements'))} | {_pct(cons.get('relationships'))} |")
272
+ return [*lines, ""]
273
+
274
+
275
+ def verdict_markdown(result: dict[str, Any]) -> list[str]:
276
+ """The comparison table (before -> after per measure) and the verdict with its reasons."""
277
+ lines = [
278
+ "## Verdict",
279
+ "",
280
+ "| Repository | Measure | Recall | Precision | Rel. recall | Rel. precision | Consistency | Better |",
281
+ "|---|---|---|---|---|---|---|---|",
282
+ ]
283
+ for r in result["rows"]:
284
+ before, after = r["baseline"] or {}, r["candidate"] or {}
285
+ better = ("yes" if r["better"] else "held") if r["measured"] else "not measured"
286
+ cells = " | ".join(f"{_pct(before.get(m))} -> {_pct(after.get(m))}" for m in MEASURES)
287
+ lines.append(f"| {r['repository']} | {_source(r['candidate'] or r['baseline'])} | {cells} | {_pct(r['consistency'])} | {better} |")
288
+ rules = result["rules"]
289
+ lines += [
290
+ "",
291
+ f"**{result['verdict']}**: {result['better']}/{result['measured']} measured repositories better by {rules['min_rise']:.1f} points or more "
292
+ f"(at least {result['needed']}), largest drop {result['max_drop']:.1f} points, lowest consistency {_pct(result['min_consistency'])}.",
293
+ ]
294
+ if result["unmeasured"]:
295
+ lines.append(f"Counted out of the {result['measured']} repositories with a measure; not measured: {', '.join(result['unmeasured'])}.")
296
+ lines += [f"- {f}" for f in result["failures"] + result.get("held", [])]
297
+ return [*lines, ""]
298
+
299
+
300
+ def write(out_dir: Path, payload: dict[str, Any], lines: list[str]) -> dict[str, str]:
301
+ """gate.json and gate.md in a folder."""
302
+ out_dir.mkdir(parents=True, exist_ok=True)
303
+ (out_dir / "gate.json").write_text(json.dumps(payload, indent=1), encoding="utf-8")
304
+ (out_dir / "gate.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
305
+ return {"json": str(out_dir / "gate.json"), "markdown": str(out_dir / "gate.md")}
@@ -29,6 +29,7 @@ class RefElement:
29
29
  aliases: tuple[str, ...] = ()
30
30
  lineage: frozenset[str] = frozenset()
31
31
  optional: bool = False
32
+ levels: frozenset[str] = frozenset({"enterprise"}) # The abstraction levels it belongs to
32
33
 
33
34
 
34
35
  @dataclass(frozen=True)
@@ -38,6 +39,7 @@ class RefRelationship:
38
39
  type: str
39
40
  kind: str
40
41
  optional: bool = False
42
+ levels: frozenset[str] = frozenset({"enterprise"})
41
43
 
42
44
 
43
45
  @dataclass(frozen=True)
@@ -52,6 +54,7 @@ class Reference:
52
54
  elements: list[RefElement] = field(default_factory=list)
53
55
  relationships: list[RefRelationship] = field(default_factory=list)
54
56
  chains: list[Chain] = field(default_factory=list)
57
+ tagged: bool = False # Whether any element or relationship names its levels; untagged, all count as enterprise
55
58
 
56
59
 
57
60
  def parse_reference(data: dict[str, Any]) -> Reference:
@@ -64,18 +67,66 @@ def parse_reference(data: dict[str, Any]) -> Reference:
64
67
  aliases=tuple(e.get("aliases", [])),
65
68
  lineage=frozenset(e.get("lineage", [])),
66
69
  optional=bool(e.get("optional", False)),
70
+ levels=_levels_of(e),
67
71
  )
68
72
  for e in data["elements"]
69
73
  ]
70
74
  keys = {e.key for e in elements}
71
75
  if len(keys) != len(elements):
72
76
  raise ValueError(f"{data['repository']}: element keys must be unique")
73
- relationships = [RefRelationship(r["source"], r["target"], r["type"], r["kind"], bool(r.get("optional", False))) for r in data.get("relationships", [])]
77
+ relationships = [RefRelationship(r["source"], r["target"], r["type"], r["kind"], bool(r.get("optional", False)), _levels_of(r)) for r in data.get("relationships", [])]
74
78
  chains = [Chain(c["name"], tuple((s[0], s[1], s[2], len(s) > 3 and s[3] == "reverse") for s in c["steps"])) for c in data.get("chains", [])]
75
79
  ends = {k for r in relationships for k in (r.source, r.target)} | {k for c in chains for s in c.steps for k in (s[0], s[2])}
76
80
  if unknown := ends - keys:
77
81
  raise ValueError(f"{data['repository']}: unknown element keys {sorted(unknown)}")
78
- return Reference(repository=data["repository"], elements=elements, relationships=relationships, chains=chains)
82
+ tagged = any("levels" in item or "cross_level" in item for item in [*data["elements"], *data.get("relationships", [])])
83
+ return Reference(repository=data["repository"], elements=elements, relationships=relationships, chains=chains, tagged=tagged)
84
+
85
+
86
+ LEVELS = ("enterprise", "solution")
87
+
88
+
89
+ def _levels_of(item: dict[str, Any]) -> frozenset[str]:
90
+ """The levels a reference or model item belongs to (``levels``); without them, the enterprise level.
91
+
92
+ A cross-level item (``cross_level``, or an explicit empty ``levels``: a roll-up between levels) belongs to no
93
+ single level and counts in the union only.
94
+ """
95
+ if item.get("cross_level"):
96
+ return frozenset()
97
+ levels = item.get("levels")
98
+ return frozenset(str(v) for v in levels) if isinstance(levels, list) else frozenset({"enterprise"})
99
+
100
+
101
+ def at_level(reference: Reference, model: dict[str, Any], level: str | None) -> tuple[Reference, dict[str, Any]]:
102
+ """The reference and the model at one level (``enterprise`` or ``solution``); ``None`` or ``union``: all of both."""
103
+ if level is None or level == "union":
104
+ return reference, model
105
+ if level not in LEVELS:
106
+ raise ValueError(f"level is one of {', '.join(LEVELS)}, union or None, not {level!r}")
107
+ keys = {e.key for e in reference.elements if level in e.levels}
108
+ leveled = Reference(
109
+ repository=reference.repository,
110
+ elements=[e for e in reference.elements if e.key in keys],
111
+ relationships=[r for r in reference.relationships if level in r.levels and r.source in keys and r.target in keys],
112
+ chains=[c for c in reference.chains if all(a in keys and b in keys for a, _, b, _ in c.steps)],
113
+ )
114
+ return leveled, snapshot_at_level(model, level)
115
+
116
+
117
+ def snapshot_at_level(model: dict[str, Any], level: str | None) -> dict[str, Any]:
118
+ """A run model at one level: its elements tagged with the level (no tag: enterprise) and the relationships on it
119
+ between them; ``None`` or ``union``: the whole model."""
120
+ if level is None or level == "union":
121
+ return model
122
+ if level not in LEVELS:
123
+ raise ValueError(f"level is one of {', '.join(LEVELS)}, union or None, not {level!r}")
124
+ ids = {e["identifier"] for e in model.get("elements", []) if level in _levels_of(e)}
125
+ return {
126
+ **model,
127
+ "elements": [e for e in model.get("elements", []) if e["identifier"] in ids],
128
+ "relationships": [r for r in model.get("relationships", []) if level in _levels_of(r) and r["source"] in ids and r["target"] in ids],
129
+ }
79
130
 
80
131
 
81
132
  def _type(value: str) -> str:
@@ -131,8 +182,13 @@ def naming(reference: Reference, model: dict[str, Any]) -> dict[str, dict[str, i
131
182
  return out
132
183
 
133
184
 
134
- def evaluate(reference: Reference, model: dict[str, Any]) -> dict[str, Any]:
135
- """Recall and precision per element type and relationship kind, splits, chains, for one model."""
185
+ def evaluate(reference: Reference, model: dict[str, Any], *, level: str | None = None) -> dict[str, Any]:
186
+ """Recall and precision per element type and relationship kind, splits, chains, for one model.
187
+
188
+ ``level`` (``enterprise``, ``solution`` or ``union``) scores one abstraction level of both; ``None`` keeps
189
+ every element and relationship, as before levels existed.
190
+ """
191
+ reference, model = at_level(reference, model, level)
136
192
  mapping = map_elements(reference, model)
137
193
  by_key = {r.key: r for r in reference.elements}
138
194
  covered: dict[str, list[str]] = defaultdict(list) # reference key -> model identifiers
@@ -59,8 +59,9 @@ def list_sessions(limit: int = Query(20, ge=1, le=500), session: Any = Depends(g
59
59
 
60
60
  @router.get("/models")
61
61
  def models(session: Any = Depends(get_benchmarks)) -> list[dict[str, Any]]:
62
- """The configured benchmark models (name, provider, model; never keys)."""
63
- return [{"name": name, "provider": cfg.provider, "model": cfg.model} for name, cfg in sorted(session.list_benchmark_models().items())]
62
+ """The configured benchmark models (name, provider, model; never keys), with the one Deriva uses marked ``default``."""
63
+ default = next((m.get("name") for m in session.list_model_configs() or [] if m.get("default")), None)
64
+ return [{"name": name, "provider": cfg.provider, "model": cfg.model, "default": name == default} for name, cfg in sorted(session.list_benchmark_models().items())]
64
65
 
65
66
 
66
67
  @router.post("", status_code=202)
@@ -141,12 +141,19 @@ def _identity_report(views: list[dict[str, Any]], identity: str) -> dict[str, An
141
141
  return report
142
142
 
143
143
 
144
- def consistency_rows(sessions: list[Path]) -> list[dict[str, Any]]:
145
- """Per repository and model (and over all models when the sessions hold several), both identities."""
144
+ def consistency_rows(sessions: list[Path], *, level: str | None = None) -> list[dict[str, Any]]:
145
+ """Per repository and model (and over all models when the sessions hold several), both identities.
146
+
147
+ ``level`` (``enterprise``, ``solution`` or ``union``) compares only that abstraction level of each run; ``None``
148
+ compares the whole model, as before levels existed.
149
+ """
150
+ from deriva.bench.reference_eval import snapshot_at_level
151
+
146
152
  grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
147
153
  for session in sessions:
148
154
  calls = _calls(session)
149
- for repo, model, number, snapshot in run_snapshots(session):
155
+ for repo, model, number, whole in run_snapshots(session):
156
+ snapshot = snapshot_at_level(whole, level)
150
157
  classified = {str(c.get("step")) for c in calls.get((repo, model, number), []) if _decision_type(c).endswith(" classification")}
151
158
  grouped.setdefault((repo, model), []).append(_run_view(snapshot, classified))
152
159
  rows = []
@@ -298,6 +305,62 @@ NAME_MATCHES = frozenset({"exact", "fuzzy_name"})
298
305
  DERIVED_TYPES = frozenset(ELEMENT_TYPES) - {"Artifact"} # Artifact belongs to the export's detail level
299
306
 
300
307
 
308
+ def accepted(entry: Any) -> tuple[list[str], list[str]]:
309
+ """The names and structural refs the user accepted for a golden element: a list of names, or
310
+ ``{"names": [...], "structure_refs": [...]}``."""
311
+ if isinstance(entry, list):
312
+ return [str(n) for n in entry], []
313
+ if isinstance(entry, dict):
314
+ return [str(n) for n in entry.get("names") or []], [str(r) for r in entry.get("structure_refs") or []]
315
+ return [], []
316
+
317
+
318
+ def _clean_path(path: str) -> str:
319
+ path = str(path).replace(chr(92), "/").strip()
320
+ while path.startswith("./"):
321
+ path = path[2:]
322
+ return path.strip("/")
323
+
324
+
325
+ def _structural_match(element: dict[str, Any], refs: list[str]) -> bool:
326
+ """A structural ref names the element's source (or lineage) id, or (``path:``) the folder or file of that id itself."""
327
+ ids = [str(i) for i in [element.get("source"), *(element.get("lineage") or [])] if i]
328
+ paths = {_clean_path(i.split("::")[2]) for i in ids if len(i.split("::")) >= 3}
329
+ return any((_clean_path(r[5:]) in paths) if r.startswith("path:") else (r in ids) for r in refs)
330
+
331
+
332
+ def golden_mapping(derived: list[dict[str, Any]], reference: list[Any], aliases: dict[str, Any] | None = None) -> dict[str, set[str]]:
333
+ """Derived element id -> the golden elements it is matched to.
334
+
335
+ First by name, as always: the element matcher (same type, the same or a similar name) and the names the user
336
+ accepted. Then a golden element still unmatched takes, through its structural refs, the first derived element of
337
+ its type (by id) that no golden element has yet, so one derived element matches one golden element there.
338
+ """
339
+ aliases = aliases or {}
340
+ mapping: dict[str, set[str]] = {}
341
+ for m in match_elements(derived, reference):
342
+ if m.match_type in NAME_MATCHES and m.derived_type == m.reference_type and m.reference_id:
343
+ mapping.setdefault(m.derived_id, set()).add(m.reference_id)
344
+ by_key: dict[tuple[str, str], list[str]] = {}
345
+ for d in derived:
346
+ by_key.setdefault((str(d.get("type")), name_key(str(d.get("name") or ""))), []).append(str(d["id"]))
347
+ for r in reference:
348
+ for alias in accepted(aliases.get(r.identifier))[0]:
349
+ for derived_id in by_key.get((r.element_type, name_key(alias)), []):
350
+ mapping.setdefault(derived_id, set()).add(r.identifier)
351
+ found = set().union(*mapping.values()) if mapping else set()
352
+ for r in sorted(reference, key=lambda r: r.identifier):
353
+ refs = accepted(aliases.get(r.identifier))[1]
354
+ if r.identifier in found or not refs:
355
+ continue
356
+ for d in sorted(derived, key=lambda d: str(d["id"])):
357
+ if str(d["id"]) not in mapping and str(d.get("type")) == r.element_type and _structural_match(d, refs):
358
+ mapping[str(d["id"])] = {r.identifier}
359
+ found.add(r.identifier)
360
+ break
361
+ return mapping
362
+
363
+
301
364
  def golden_comparison(
302
365
  derived: list[dict[str, Any]],
303
366
  reference: list[Any],
@@ -312,12 +375,16 @@ def golden_comparison(
312
375
  derived types. Precision counts each found reference element once, as the model-quality report does.
313
376
  """
314
377
  aliases, derivable = aliases or {}, derivable or {}
315
- found = {m.reference_id for m in match_elements(derived, reference) if m.match_type in NAME_MATCHES and m.derived_type == m.reference_type}
378
+ by_name = {m.reference_id for m in match_elements(derived, reference) if m.match_type in NAME_MATCHES and m.derived_type == m.reference_type}
316
379
  by_type: dict[str, set[str]] = {}
317
380
  for d in derived:
318
381
  by_type.setdefault(str(d.get("type")), set()).add(name_key(str(d.get("name") or "")))
319
- manual = {r.identifier for r in reference if r.identifier not in found and any(name_key(a) in by_type.get(r.element_type, set()) for a in aliases.get(r.identifier, []))}
320
- found |= manual
382
+ manual = {
383
+ r.identifier for r in reference if r.identifier not in by_name and any(name_key(a) in by_type.get(r.element_type, set()) for a in accepted(aliases.get(r.identifier))[0])
384
+ }
385
+ mapped = golden_mapping(derived, reference, aliases)
386
+ found = set().union(*mapped.values()) if mapped else set()
387
+ structural = found - by_name - manual
321
388
 
322
389
  def share(part: int, whole: int) -> float | None:
323
390
  return round(part / whole, 3) if whole else None
@@ -340,6 +407,7 @@ def golden_comparison(
340
407
  "derivable_recall": share(sum(1 for r in can if r.identifier in found), len(can)),
341
408
  "derivable_labelled": len(labelled),
342
409
  "manual_matches": len(manual),
410
+ "structure_matches": len(structural),
343
411
  }
344
412
 
345
413
 
@@ -1,216 +0,0 @@
1
- """The gate for a change: recall, precision and consistency per repository, a candidate against a baseline.
2
-
3
- Per repository (read-only, from the sessions' run snapshots):
4
-
5
- - **A development reference** (``<goldens>/<name>.json``, the reference format of ``reference_eval``): element
6
- recall and precision, the mean over the runs.
7
- - **A golden model** (``<reference>/<repository>.archimate``): recall on the derivable subset the user labelled
8
- (``<repository>.derivable.json``) with the user's accepted names (``<repository>.matches.json``), and precision.
9
- Without those files the ``.template.json`` versions are read and the result is provisional: recall then counts the
10
- reference elements of the derived types.
11
- - **Consistency by id**: the share of elements and of relationships in every run (``session_metrics``).
12
-
13
- A repository without either reference keeps its consistency and is left out of the count. The verdict: the change is
14
- accepted when recall or precision rises on enough of the measured repositories, none drops by more than the allowed
15
- points on either, and consistency stays at the minimum everywhere.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import json
21
- import math
22
- from dataclasses import dataclass
23
- from pathlib import Path
24
- from typing import Any
25
-
26
- from deriva.bench import reference_eval, session_metrics
27
- from deriva.modules.analysis.semantic_matching import parse_archi_xml, parse_exchange_format_xml
28
-
29
-
30
- @dataclass(frozen=True)
31
- class GateRules:
32
- """The acceptance rule."""
33
-
34
- better_share: float = 2 / 3 # Of the measured repositories, at least this share better on recall or precision (6 of 9)
35
- max_drop: float = 2.0 # Points either may drop on any repository
36
- min_consistency: float = 0.95 # Element and relationship consistency by id on every repository
37
-
38
-
39
- def _user_file(folder: Path, repository: str, kind: str) -> tuple[dict[str, Any], bool]:
40
- """The user's file (matches or derivable), else its template; True when the template was read."""
41
- for name, template in ((f"{repository}.{kind}.json", False), (f"{repository}.{kind}.template.json", True)):
42
- path = folder / name
43
- if path.is_file():
44
- try:
45
- return json.loads(path.read_text(encoding="utf-8")), template
46
- except ValueError:
47
- return {}, True
48
- return {}, True
49
-
50
-
51
- def _golden_elements(path: Path) -> list[Any]:
52
- elements, _ = parse_archi_xml(path)
53
- return elements or parse_exchange_format_xml(path)[0]
54
-
55
-
56
- def _mean(values: list[float | None]) -> float | None:
57
- known = [v for v in values if v is not None]
58
- return round(sum(known) / len(known), 3) if known else None
59
-
60
-
61
- def measure(sessions: list[Path], goldens_dir: Path, reference_dir: Path, model: str | None = None) -> dict[str, dict[str, Any]]:
62
- """Per repository of the sessions: its model, runs, source of the measure, recall, precision and consistency by id."""
63
- sessions = [Path(s) for s in sessions]
64
- runs: dict[str, dict[str, list[dict[str, Any]]]] = {}
65
- for session in sessions:
66
- for repo, run_model, _, snapshot in session_metrics.run_snapshots(session):
67
- runs.setdefault(repo, {}).setdefault(run_model, []).append(snapshot)
68
- references = reference_eval.load_references(goldens_dir) if Path(goldens_dir).is_dir() else {}
69
- consistency = {(r["repository"], r["model"]): r["identities"]["id"] for r in session_metrics.consistency_rows(sessions)}
70
-
71
- out: dict[str, dict[str, Any]] = {}
72
- for repo, by_model in sorted(runs.items()):
73
- if model is not None:
74
- snapshots = by_model.get(model, [])
75
- if not snapshots:
76
- continue
77
- chosen = model
78
- elif len(by_model) > 1:
79
- raise ValueError(f"{repo} has runs of several models ({', '.join(sorted(by_model))}): name one with --model")
80
- else:
81
- ((chosen, snapshots),) = by_model.items()
82
- row: dict[str, Any] = {"repository": repo, "model": chosen, "runs": len(snapshots), "source": None, "provisional": False, "recall": None, "precision": None}
83
- golden = Path(reference_dir) / f"{repo}.archimate"
84
- if repo in references:
85
- results = [reference_eval.evaluate(references[repo], s) for s in snapshots]
86
- row |= {"source": "reference", "recall": _mean([r["element_recall"] for r in results]), "precision": _mean([r["element_precision"] for r in results])}
87
- elif golden.is_file():
88
- reference = _golden_elements(golden)
89
- aliases, alias_template = _user_file(Path(reference_dir), repo, "matches")
90
- derivable, derivable_template = _user_file(Path(reference_dir), repo, "derivable")
91
- provisional = alias_template or derivable_template
92
- results = []
93
- for snapshot in snapshots:
94
- derived = [{"id": e["identifier"], "name": e.get("name"), "type": str(e["type"]).removeprefix("Model:")} for e in snapshot.get("elements", [])]
95
- results.append(session_metrics.golden_comparison(derived, reference, aliases=aliases, derivable=derivable))
96
- recall_key = "derivable_types_recall" if provisional else "derivable_recall"
97
- row |= {
98
- "source": "golden",
99
- "provisional": provisional,
100
- "recall": _mean([r[recall_key] for r in results]),
101
- "precision": _mean([r["precision"] for r in results]),
102
- }
103
- ids = consistency.get((repo, chosen))
104
- row["consistency"] = {"elements": ids["elements"]["cons"], "relationships": ids["relationships"]["cons"]} if ids else None
105
- out[repo] = row
106
- return out
107
-
108
-
109
- def _points(before: float | None, after: float | None) -> float | None:
110
- return None if before is None or after is None else round((after - before) * 100, 1)
111
-
112
-
113
- def _lowest(row: dict[str, Any]) -> float | None:
114
- values = [v for v in (row.get("consistency") or {}).values() if v is not None]
115
- return min(values) if values else None
116
-
117
-
118
- def verdict(baseline: dict[str, dict[str, Any]], candidate: dict[str, dict[str, Any]], rules: GateRules | None = None) -> dict[str, Any]:
119
- """The candidate against the baseline, per repository and overall (PASS or FAIL, with the reasons)."""
120
- rules = rules or GateRules()
121
- rows, failures, unmeasured = [], [], []
122
- for repo in sorted(set(baseline) | set(candidate)):
123
- before, after = baseline.get(repo), candidate.get(repo)
124
- measured = bool(before and after and before["recall"] is not None and after["recall"] is not None)
125
- row: dict[str, Any] = {"repository": repo, "measured": measured, "baseline": before, "candidate": after, "better": False}
126
- if measured and before and after:
127
- row["recall_points"] = _points(before["recall"], after["recall"])
128
- row["precision_points"] = _points(before["precision"], after["precision"])
129
- changes = [p for p in (row["recall_points"], row["precision_points"]) if p is not None]
130
- row["better"] = any(p > 0 for p in changes)
131
- row["drop"] = max([0.0] + [-p for p in changes])
132
- if row["drop"] > rules.max_drop:
133
- worst = "recall" if (row["recall_points"] or 0) <= (row["precision_points"] or 0) else "precision"
134
- failures.append(f"{repo} drops {row['drop']:.1f} points on {worst} (at most {rules.max_drop:.1f})")
135
- else:
136
- unmeasured.append(repo)
137
- lowest = _lowest(after) if after else None
138
- row["consistency"] = lowest
139
- if lowest is not None and lowest < rules.min_consistency:
140
- failures.append(f"{repo} consistency {lowest:.1%} (at least {rules.min_consistency:.0%})")
141
- rows.append(row)
142
- measured_rows = [r for r in rows if r["measured"]]
143
- better = sum(1 for r in measured_rows if r["better"])
144
- needed = math.ceil(rules.better_share * len(measured_rows) - 1e-9)
145
- if not measured_rows:
146
- failures.append("no repository has a measure in both the baseline and the candidate")
147
- elif better < needed:
148
- failures.append(f"{better} of {len(measured_rows)} measured repositories better (at least {needed})")
149
- consistencies = [r["consistency"] for r in rows if r["consistency"] is not None]
150
- return {
151
- "rows": rows,
152
- "measured": len(measured_rows),
153
- "better": better,
154
- "needed": needed,
155
- "max_drop": max((r["drop"] for r in measured_rows), default=0.0),
156
- "min_consistency": min(consistencies) if consistencies else None,
157
- "unmeasured": unmeasured,
158
- "failures": failures,
159
- "verdict": "FAIL" if failures else "PASS",
160
- "rules": {"better_share": rules.better_share, "max_drop": rules.max_drop, "min_consistency": rules.min_consistency},
161
- }
162
-
163
-
164
- def _pct(value: float | None) -> str:
165
- return "-" if value is None else f"{value:.1%}"
166
-
167
-
168
- def _source(row: dict[str, Any] | None) -> str:
169
- if not row or row["source"] is None:
170
- return "no reference"
171
- return f"{row['source']} (provisional)" if row["provisional"] else row["source"]
172
-
173
-
174
- def table_markdown(measures: dict[str, dict[str, Any]], title: str) -> list[str]:
175
- """One row per repository: source, runs, recall, precision, consistency by id."""
176
- lines = [f"## {title}", "", "| Repository | Measure | Runs | Recall | Precision | Elements by id | Relationships by id |", "|---|---|---|---|---|---|---|"]
177
- for repo, row in sorted(measures.items()):
178
- cons = row.get("consistency") or {}
179
- lines.append(
180
- f"| {repo} | {_source(row)} | {row['runs']} | {_pct(row['recall'])} | {_pct(row['precision'])} | {_pct(cons.get('elements'))} | {_pct(cons.get('relationships'))} |"
181
- )
182
- return [*lines, ""]
183
-
184
-
185
- def verdict_markdown(result: dict[str, Any]) -> list[str]:
186
- """The comparison table and the verdict with its reasons."""
187
- lines = [
188
- "## Verdict",
189
- "",
190
- "| Repository | Measure | Recall before | Recall after | Precision before | Precision after | Consistency | Better |",
191
- "|---|---|---|---|---|---|---|---|",
192
- ]
193
- for r in result["rows"]:
194
- before, after = r["baseline"] or {}, r["candidate"] or {}
195
- better = ("yes" if r["better"] else "no") if r["measured"] else "not measured"
196
- lines.append(
197
- f"| {r['repository']} | {_source(r['candidate'] or r['baseline'])} | {_pct(before.get('recall'))} | {_pct(after.get('recall'))} | "
198
- f"{_pct(before.get('precision'))} | {_pct(after.get('precision'))} | {_pct(r['consistency'])} | {better} |"
199
- )
200
- lines += [
201
- "",
202
- f"**{result['verdict']}**: {result['better']}/{result['measured']} measured repositories better (at least {result['needed']}), "
203
- f"largest drop {result['max_drop']:.1f} points, lowest consistency {_pct(result['min_consistency'])}.",
204
- ]
205
- if result["unmeasured"]:
206
- lines.append(f"Counted out of the {result['measured']} repositories with a measure; not measured: {', '.join(result['unmeasured'])}.")
207
- lines += [f"- {f}" for f in result["failures"]]
208
- return [*lines, ""]
209
-
210
-
211
- def write(out_dir: Path, payload: dict[str, Any], lines: list[str]) -> dict[str, str]:
212
- """gate.json and gate.md in a folder."""
213
- out_dir.mkdir(parents=True, exist_ok=True)
214
- (out_dir / "gate.json").write_text(json.dumps(payload, indent=1), encoding="utf-8")
215
- (out_dir / "gate.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
216
- return {"json": str(out_dir / "gate.json"), "markdown": str(out_dir / "gate.md")}
File without changes
File without changes