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.
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/PKG-INFO +2 -2
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/pyproject.toml +2 -2
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/pyproject.toml.orig +2 -2
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/benchmarking.py +45 -9
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/cli.py +21 -11
- deriva_dev-0.8.3/src/deriva/bench/gate.py +305 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/reference_eval.py +60 -4
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/router.py +3 -2
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/session_metrics.py +74 -6
- deriva_dev-0.8.2/src/deriva/bench/gate.py +0 -216
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/LICENSE +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/README.md +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/__init__.py +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/analysis.py +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/baseline_b1.py +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/benchmark_views.py +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/config_deviation.py +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/conventions_report.py +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/data/conventions.json +0 -0
- {deriva_dev-0.8.2 → deriva_dev-0.8.3}/src/deriva/bench/session.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2203
|
+
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) if snapshot_path.is_file() else {}
|
|
2180
2204
|
except OSError, ValueError:
|
|
2181
|
-
|
|
2205
|
+
snapshot = {}
|
|
2206
|
+
row["solve"] = snapshot.get("solve")
|
|
2182
2207
|
row["reference"] = None
|
|
2183
2208
|
if reference:
|
|
2184
|
-
#
|
|
2185
|
-
|
|
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
|
-
"""
|
|
386
|
+
"""Element and relationship recall and precision and consistency by id per repository, and the verdict of a candidate.
|
|
387
387
|
|
|
388
|
-
Accepted when
|
|
389
|
-
than 2 points
|
|
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 = {
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
lines += gate.table_markdown(
|
|
411
|
-
payload
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 = {
|
|
320
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|