deriva-dev 0.8.0__tar.gz → 0.8.2__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.0 → deriva_dev-0.8.2}/PKG-INFO +2 -2
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/pyproject.toml +2 -2
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/pyproject.toml.orig +2 -2
- deriva_dev-0.8.2/src/deriva/bench/baseline_b1.py +265 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/benchmarking.py +408 -34
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/cli.py +267 -0
- deriva_dev-0.8.2/src/deriva/bench/conventions_report.py +79 -0
- deriva_dev-0.8.2/src/deriva/bench/data/conventions.json +27 -0
- deriva_dev-0.8.2/src/deriva/bench/gate.py +216 -0
- deriva_dev-0.8.2/src/deriva/bench/reference_eval.py +304 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/session.py +5 -0
- deriva_dev-0.8.2/src/deriva/bench/session_metrics.py +364 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/LICENSE +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/README.md +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/__init__.py +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/analysis.py +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/benchmark_views.py +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/config_deviation.py +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/src/deriva/bench/router.py +0 -0
- {deriva_dev-0.8.0 → deriva_dev-0.8.2}/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.2
|
|
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.2
|
|
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.2"
|
|
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.2"]
|
|
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.2"
|
|
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.2"]
|
|
12
12
|
|
|
13
13
|
[project.urls]
|
|
14
14
|
Homepage = "https://deriva.dev"
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Baseline B1: an LLM generates a whole ArchiMate model from raw repository material in one go.
|
|
2
|
+
|
|
3
|
+
Following Nast et al. (BIS 2026), one template-driven generation produces the complete model; the template is a typed
|
|
4
|
+
output with closed enums for the element and relationship types. The answer is validated (duplicate ids, unknown
|
|
5
|
+
endpoints, relationships the ArchiMate 3.2 relationship table does not allow) and repaired at most ``max_repairs``
|
|
6
|
+
times with the error list; after that the invalid relationships (and duplicate elements) are dropped. The repair loop
|
|
7
|
+
is our own composition, not taken from a published design. k = 1: no sampling, no voting.
|
|
8
|
+
|
|
9
|
+
The input is raw repository material within a fixed character budget, the same for every model, selected without
|
|
10
|
+
Deriva's graph: the README, the directory tree, manifest, build and deployment files, and a deterministic sample of
|
|
11
|
+
source files (the largest first, tests left out). The prompt text lives in the ``BaselineB1`` config row (phase
|
|
12
|
+
``baseline``, never run by the pipeline); this module holds only structure.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from deriva.adapters.archimate.models import Element, Relationship, validate_relationship_rule
|
|
25
|
+
from deriva.common.json_utils import extract_json_from_response
|
|
26
|
+
from deriva.common.naming import name_key
|
|
27
|
+
from deriva.modules.derivation.base import element_identifier
|
|
28
|
+
|
|
29
|
+
SCHEMA: dict[str, Any] = {"name": "archimate_model_generation"}
|
|
30
|
+
CONFIG_STEP = "BaselineB1" # the config row with the instruction and params (phase baseline)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class B1Params:
|
|
35
|
+
"""The ``BaselineB1`` config row's params (versioned)."""
|
|
36
|
+
|
|
37
|
+
budget_chars: int = 120_000
|
|
38
|
+
readme_chars: int = 6_000
|
|
39
|
+
tree_depth: int = 4
|
|
40
|
+
tree_entries: int = 400
|
|
41
|
+
per_file_chars: int = 6_000
|
|
42
|
+
max_repairs: int = 2
|
|
43
|
+
manifest_patterns: tuple[str, ...] = (
|
|
44
|
+
"package.json", "pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "pyproject.toml", "requirements*.txt",
|
|
45
|
+
"setup.py", "go.mod", "cargo.toml", "*.csproj", "*.sln", "makefile", "dockerfile", "dockerfile.*", "*.dockerfile",
|
|
46
|
+
"docker-compose*.yml", "docker-compose*.yaml", "compose*.yml", "compose*.yaml", "cdk.json", "*.tf", "serverless.yml",
|
|
47
|
+
) # fmt: skip
|
|
48
|
+
source_extensions: tuple[str, ...] = (
|
|
49
|
+
".java", ".py", ".js", ".jsx", ".ts", ".tsx", ".cs", ".go", ".rb", ".php", ".kt", ".scala", ".cbl", ".cob", ".jcl", ".pl",
|
|
50
|
+
) # fmt: skip
|
|
51
|
+
test_markers: tuple[str, ...] = ("test", "tests", "spec", "specs", "__tests__", "testing")
|
|
52
|
+
excluded_directories: tuple[str, ...] = (".git", "node_modules", "target", "build", "dist", "vendor", ".venv", "venv", "__pycache__")
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_params(cls, params: dict[str, Any] | None, excluded: list[str] | None = None) -> B1Params:
|
|
56
|
+
values = dict(params or {})
|
|
57
|
+
for key in ("manifest_patterns", "source_extensions", "test_markers"):
|
|
58
|
+
if key in values:
|
|
59
|
+
values[key] = tuple(values[key])
|
|
60
|
+
if excluded:
|
|
61
|
+
values["excluded_directories"] = tuple(excluded)
|
|
62
|
+
return cls(**{k: v for k, v in values.items() if k in cls.__dataclass_fields__})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _files(root: Path, params: B1Params) -> list[tuple[str, int]]:
|
|
66
|
+
"""(repository-relative path, size) of every file outside the excluded directories, in path order."""
|
|
67
|
+
found = []
|
|
68
|
+
for path in root.rglob("*"):
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
continue
|
|
71
|
+
rel = path.relative_to(root).as_posix()
|
|
72
|
+
if any(part in params.excluded_directories for part in PurePosixPath(rel).parts[:-1]):
|
|
73
|
+
continue
|
|
74
|
+
found.append((rel, path.stat().st_size))
|
|
75
|
+
return sorted(found)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_test(rel: str, params: B1Params) -> bool:
|
|
79
|
+
parts = [p.lower() for p in PurePosixPath(rel).parts]
|
|
80
|
+
stem = PurePosixPath(rel).stem.lower()
|
|
81
|
+
return any(p in params.test_markers for p in parts[:-1]) or stem.startswith("test") or stem.endswith(("test", "tests", "spec"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read(root: Path, rel: str, limit: int) -> str:
|
|
85
|
+
try:
|
|
86
|
+
text = (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
87
|
+
except OSError:
|
|
88
|
+
return ""
|
|
89
|
+
return text if len(text) <= limit else text[:limit] + "\n[... truncated]"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def repository_material(root: Path, params: B1Params) -> tuple[str, dict[str, Any]]:
|
|
93
|
+
"""The input text (README, tree, manifests, sources by size) within the budget, and what it holds."""
|
|
94
|
+
files = _files(root, params)
|
|
95
|
+
parts: list[str] = []
|
|
96
|
+
manifest: dict[str, Any] = {"readme": None, "manifests": [], "sources": [], "tree_entries": 0}
|
|
97
|
+
used = 0
|
|
98
|
+
|
|
99
|
+
def add(text: str) -> bool:
|
|
100
|
+
nonlocal used
|
|
101
|
+
if used + len(text) > params.budget_chars:
|
|
102
|
+
return False
|
|
103
|
+
parts.append(text)
|
|
104
|
+
used += len(text)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
readme = next((rel for rel, _ in files if PurePosixPath(rel).parent == PurePosixPath(".") and PurePosixPath(rel).name.lower().startswith("readme")), None)
|
|
108
|
+
if readme and add(f"## README ({readme})\n{_read(root, readme, params.readme_chars)}\n\n"):
|
|
109
|
+
manifest["readme"] = readme
|
|
110
|
+
entries: set[str] = set()
|
|
111
|
+
for rel, _ in files:
|
|
112
|
+
segments = PurePosixPath(rel).parts
|
|
113
|
+
for depth in range(1, min(params.tree_depth, len(segments)) + 1):
|
|
114
|
+
entries.add("/".join(segments[:depth]) + ("/" if depth < len(segments) else ""))
|
|
115
|
+
tree = sorted(entries)
|
|
116
|
+
shown = tree[: params.tree_entries]
|
|
117
|
+
if add("## Directory tree\n" + "\n".join(shown) + ("\n[...]" if len(tree) > len(shown) else "") + "\n\n"):
|
|
118
|
+
manifest["tree_entries"] = len(shown)
|
|
119
|
+
for rel, _ in files:
|
|
120
|
+
if any(fnmatch.fnmatchcase(PurePosixPath(rel).name.lower(), p) for p in params.manifest_patterns) and add(f"## File {rel}\n{_read(root, rel, params.per_file_chars)}\n\n"):
|
|
121
|
+
manifest["manifests"].append(rel)
|
|
122
|
+
sources = sorted(
|
|
123
|
+
((rel, size) for rel, size in files if rel.lower().endswith(params.source_extensions) and not _is_test(rel, params)),
|
|
124
|
+
key=lambda item: (-item[1], item[0]),
|
|
125
|
+
)
|
|
126
|
+
for rel, _ in sources:
|
|
127
|
+
if not add(f"## Source {rel}\n{_read(root, rel, params.per_file_chars)}\n\n"):
|
|
128
|
+
break
|
|
129
|
+
manifest["sources"].append(rel)
|
|
130
|
+
manifest["chars"] = used
|
|
131
|
+
return "".join(parts), manifest
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_prompt(instruction: str, material: str) -> str:
|
|
135
|
+
"""The generation prompt: the configured instruction, the material and the output contract."""
|
|
136
|
+
return f"""{instruction}
|
|
137
|
+
|
|
138
|
+
# Repository material
|
|
139
|
+
{material}
|
|
140
|
+
# Output
|
|
141
|
+
Return {{"elements": [{{"id", "type", "name"}}], "relationships": [{{"source", "target", "type"}}]}}: every element once, relationships between element ids."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_repair_prompt(prompt: str, answer: dict[str, Any], errors: list[str]) -> str:
|
|
145
|
+
"""The repair prompt: the original prompt, the previous answer and its errors."""
|
|
146
|
+
listed = "\n".join(f"- {e}" for e in errors)
|
|
147
|
+
return f"""{prompt}
|
|
148
|
+
|
|
149
|
+
## Previous answer
|
|
150
|
+
```json
|
|
151
|
+
{json.dumps(answer, sort_keys=True)}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Errors
|
|
155
|
+
{listed}
|
|
156
|
+
|
|
157
|
+
Return the complete corrected model."""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def validate(model: dict[str, Any]) -> list[str]:
|
|
161
|
+
"""Duplicate ids, unknown endpoints and relationships the ArchiMate relationship table does not allow."""
|
|
162
|
+
errors: list[str] = []
|
|
163
|
+
types: dict[str, str] = {}
|
|
164
|
+
for e in model.get("elements", []):
|
|
165
|
+
if e["id"] in types:
|
|
166
|
+
errors.append(f"duplicate id {e['id']}")
|
|
167
|
+
continue
|
|
168
|
+
types[e["id"]] = e["type"]
|
|
169
|
+
for r in model.get("relationships", []):
|
|
170
|
+
for end in ("source", "target"):
|
|
171
|
+
if r[end] not in types:
|
|
172
|
+
errors.append(f"relationship {r['source']} -{r['type']}-> {r['target']}: unknown {end} {r[end]}")
|
|
173
|
+
if r["source"] in types and r["target"] in types:
|
|
174
|
+
ok, reason = validate_relationship_rule(types[r["source"]], r["type"], types[r["target"]])
|
|
175
|
+
if not ok:
|
|
176
|
+
errors.append(f"relationship {r['source']} -{r['type']}-> {r['target']}: {reason}")
|
|
177
|
+
return errors
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse(response: Any) -> dict[str, Any] | None:
|
|
181
|
+
content = getattr(response, "content", None)
|
|
182
|
+
if content is None and response is not None and hasattr(response, "model_dump"):
|
|
183
|
+
return response.model_dump()
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(extract_json_from_response(str(content or "")))
|
|
186
|
+
except ValueError:
|
|
187
|
+
return None
|
|
188
|
+
if not isinstance(data, dict):
|
|
189
|
+
return None
|
|
190
|
+
return {
|
|
191
|
+
"elements": [e for e in data.get("elements", []) if isinstance(e, dict) and {"id", "type", "name"} <= set(e)],
|
|
192
|
+
"relationships": [r for r in data.get("relationships", []) if isinstance(r, dict) and {"source", "target", "type"} <= set(r)],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _cleaned(model: dict[str, Any]) -> tuple[dict[str, Any], int, int]:
|
|
197
|
+
"""The model without duplicate elements and invalid relationships (dropped counts)."""
|
|
198
|
+
elements, seen = [], set()
|
|
199
|
+
for e in model.get("elements", []):
|
|
200
|
+
if e["id"] not in seen:
|
|
201
|
+
seen.add(e["id"])
|
|
202
|
+
elements.append(e)
|
|
203
|
+
kept = {"elements": elements, "relationships": []}
|
|
204
|
+
for r in model.get("relationships", []):
|
|
205
|
+
if not validate({"elements": elements, "relationships": [r]}):
|
|
206
|
+
kept["relationships"].append(r)
|
|
207
|
+
return kept, len(model.get("elements", [])) - len(elements), len(model.get("relationships", [])) - len(kept["relationships"])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def generate(llm_query_fn: Callable[..., Any], instruction: str, material: str, params: B1Params) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
211
|
+
"""Generate, validate and repair (at most ``max_repairs`` times); the cleaned model and a report."""
|
|
212
|
+
prompt = build_prompt(instruction, material)
|
|
213
|
+
report = {"calls": 0, "repairs": 0, "errors_left": 0, "dropped_relationships": 0, "dropped_elements": 0, "parse_failures": 0}
|
|
214
|
+
model: dict[str, Any] = {"elements": [], "relationships": []}
|
|
215
|
+
current = prompt
|
|
216
|
+
for attempt in range(params.max_repairs + 1):
|
|
217
|
+
report["calls"] += 1
|
|
218
|
+
parsed = _parse(llm_query_fn(current, SCHEMA))
|
|
219
|
+
if parsed is None:
|
|
220
|
+
report["parse_failures"] += 1
|
|
221
|
+
errors = ["the answer is no readable JSON model"]
|
|
222
|
+
else:
|
|
223
|
+
model = parsed
|
|
224
|
+
errors = validate(model)
|
|
225
|
+
if not errors or attempt == params.max_repairs:
|
|
226
|
+
report["errors_left"] = len(errors)
|
|
227
|
+
break
|
|
228
|
+
report["repairs"] += 1
|
|
229
|
+
current = build_repair_prompt(prompt, model, errors)
|
|
230
|
+
model, report["dropped_elements"], report["dropped_relationships"] = _cleaned(model)
|
|
231
|
+
return model, report
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def element_id(element_type: str, name: str) -> str:
|
|
235
|
+
"""B1 has no source nodes: its identity is the type and the canonical name key."""
|
|
236
|
+
return element_identifier(element_type, f"b1::{name_key(name) or 'unnamed'}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def to_archimate(model: dict[str, Any]) -> tuple[list[Element], list[Relationship]]:
|
|
240
|
+
"""ArchiMate elements and relationships (ids from type and name; a repeated identity is kept once)."""
|
|
241
|
+
ids: dict[str, str] = {}
|
|
242
|
+
elements: list[Element] = []
|
|
243
|
+
for e in model.get("elements", []):
|
|
244
|
+
identifier = element_id(e["type"], e["name"])
|
|
245
|
+
ids[e["id"]] = identifier
|
|
246
|
+
if all(x.identifier != identifier for x in elements):
|
|
247
|
+
elements.append(Element(name=e["name"], element_type=e["type"], identifier=identifier, properties={"source": None, "derived_from": "llm_b1"}))
|
|
248
|
+
relationships: list[Relationship] = []
|
|
249
|
+
seen: set[tuple[str, str, str]] = set()
|
|
250
|
+
for r in model.get("relationships", []):
|
|
251
|
+
key = (ids[r["source"]], r["type"], ids[r["target"]])
|
|
252
|
+
if key in seen or key[0] == key[2]:
|
|
253
|
+
continue
|
|
254
|
+
seen.add(key)
|
|
255
|
+
identifier = f"rel_b1_{r['type'].lower()}_{key[0]}_{key[2]}"
|
|
256
|
+
relationships.append(Relationship(source=key[0], target=key[2], relationship_type=r["type"], identifier=identifier, properties={"derived_from": "llm_b1"}))
|
|
257
|
+
return elements, relationships
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class B1Result:
|
|
262
|
+
elements: list[Element] = field(default_factory=list)
|
|
263
|
+
relationships: list[Relationship] = field(default_factory=list)
|
|
264
|
+
report: dict[str, Any] = field(default_factory=dict)
|
|
265
|
+
material: dict[str, Any] = field(default_factory=dict)
|