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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deriva-dev
3
- Version: 0.8.0
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.0
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.0"
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.0"]
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.0"
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.0"]
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)