ineedvalidation 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,12 @@
1
+ """Evidence declarations for tests and a validation hierarchy built from them.
2
+
3
+ The test half is a pytest plugin: :func:`case`, :func:`seam` and
4
+ :func:`regression` mark what a test demonstrates, and the plugin collects those
5
+ marks into an evidence ledger. The render half reads hierarchy node notes plus
6
+ that ledger, lints them against each other, and renders the hierarchy.
7
+ """
8
+
9
+ from ineedvalidation._version import __version__
10
+ from ineedvalidation.marks import TIERS, case, regression, seam
11
+
12
+ __all__ = ["TIERS", "__version__", "case", "regression", "seam"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
ineedvalidation/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ """Command-line entry point: ``ineedvalidation lint | render | scaffold``."""
2
+
3
+ import argparse
4
+ import sys
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ from ineedvalidation import __version__, d2, evidence, nodes, views
9
+ from ineedvalidation.schema import HIERARCHY_TIERS
10
+
11
+ STUB = """---
12
+ id: {stub_id}
13
+ tier: ''
14
+ title: {case}
15
+ couples_to: []
16
+ cases:
17
+ - {case}
18
+ srqs: []
19
+ referent: ''
20
+ referent_status: none
21
+ validation_level: 0
22
+ ---
23
+ # {case}
24
+
25
+ Stub written by ineedvalidation scaffold. Fill in the tier, the coupling, the
26
+ system response quantities and the referent, then delete this line.
27
+ """
28
+
29
+
30
+ def build_parser():
31
+ """Build the argument parser."""
32
+ parser = argparse.ArgumentParser(
33
+ prog="ineedvalidation",
34
+ description=(
35
+ "Lint and render a validation hierarchy from node notes and test evidence."
36
+ ),
37
+ )
38
+ parser.add_argument("--version", action="version", version=__version__)
39
+ sub = parser.add_subparsers(dest="command", required=True)
40
+ for name in ("lint", "render", "scaffold"):
41
+ command = sub.add_parser(name)
42
+ command.add_argument(
43
+ "hierarchy", help="directory holding nodes/, evidence/ and views/"
44
+ )
45
+ command.add_argument("--nodes", help="node notes directory")
46
+ command.add_argument("--evidence", help="evidence files directory")
47
+ lint = sub.choices["lint"]
48
+ lint.add_argument("--library-map", help="file listing the known library names")
49
+ render = sub.choices["render"]
50
+ render.add_argument("--view", help="render only this named view")
51
+ render.add_argument(
52
+ "--all-views", action="store_true", help="render every view file as well"
53
+ )
54
+ render.add_argument("--root", help="render only this node and everything below it")
55
+ render.add_argument("--views", help="view files directory")
56
+ render.add_argument("--out", help="output directory")
57
+ return parser
58
+
59
+
60
+ def _load(args):
61
+ """Load the nodes, the evidence files and the merged summary."""
62
+ root = Path(args.hierarchy)
63
+ node_dir = Path(args.nodes) if args.nodes else root / "nodes"
64
+ evidence_dir = Path(args.evidence) if args.evidence else root / "evidence"
65
+ loaded = nodes.load(node_dir)
66
+ files = evidence.load_dir(evidence_dir)
67
+ return root, loaded, files, evidence.summarize(loaded, files)
68
+
69
+
70
+ def _lint(args):
71
+ """Report every problem in the hierarchy."""
72
+ _, loaded, files, summary = _load(args)
73
+ library_text = Path(args.library_map).read_text() if args.library_map else None
74
+ # Without a ledger there is nothing to lint the notes against, so the
75
+ # two-way rules stay off and the declared fields carry the evidence.
76
+ problems = nodes.lint(
77
+ loaded,
78
+ summary=summary if files else None,
79
+ library_text=library_text,
80
+ unfiled=evidence.unfiled_cases(loaded, files) if files else None,
81
+ )
82
+ for problem in problems:
83
+ print("LINT:", problem)
84
+ census = ", ".join(
85
+ f"{tier}={sum(node.tier == tier for node in loaded.values())}"
86
+ for tier in HIERARCHY_TIERS
87
+ )
88
+ print(f"{len(loaded)} nodes, {census}")
89
+ return 1 if problems else 0
90
+
91
+
92
+ def _render(args):
93
+ """Render one view, the two built-in views, or every view."""
94
+ root, loaded, _, summary = _load(args)
95
+ views_dir = Path(args.views) if args.views else root / "views"
96
+ outdir = Path(args.out) if args.out else root / "output"
97
+ names = [args.view] if args.view else list(views.builtin())
98
+ if args.all_views:
99
+ names = sorted(set(names) | set(views.load_dir(views_dir)))
100
+ for name in names:
101
+ try:
102
+ view = views.resolve(name, views_dir)
103
+ except KeyError as error:
104
+ print(error.args[0], file=sys.stderr)
105
+ return 2
106
+ if args.root:
107
+ view = replace(
108
+ view,
109
+ name=f"{name}_{args.root}",
110
+ root=args.root,
111
+ title=view.title or loaded[args.root].title,
112
+ )
113
+ selected = nodes.subtree(loaded, view.root) if view.root else loaded
114
+ d2.render(selected, summary, view, outdir, views_dir)
115
+ print("rendered", outdir / f"{view.name}.png")
116
+ return 0
117
+
118
+
119
+ def _scaffold(args):
120
+ """Write a stub note for every case no node owns."""
121
+ root, loaded, files, _ = _load(args)
122
+ node_dir = Path(args.nodes) if args.nodes else root / "nodes"
123
+ written = 0
124
+ for case, libraries in evidence.unfiled_cases(loaded, files).items():
125
+ target = node_dir / f"unfiled-{case}.md"
126
+ if target.exists():
127
+ print("skipped, already present:", target)
128
+ continue
129
+ target.write_text(STUB.format(stub_id=f"unfiled-{case}", case=case))
130
+ print("wrote", target, "for", ", ".join(libraries))
131
+ written += 1
132
+ print(f"{written} stub(s) written")
133
+ return 0
134
+
135
+
136
+ def main(argv=None):
137
+ """Run the command line."""
138
+ args = build_parser().parse_args(argv)
139
+ return {"lint": _lint, "render": _render, "scaffold": _scaffold}[args.command](args)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
ineedvalidation/d2.py ADDED
@@ -0,0 +1,344 @@
1
+ """Emit the tiered hierarchy diagram as D2, and drive the rendering binaries.
2
+
3
+ The layout is deterministic: one row per tier, uniform boxes, and an invisible
4
+ anchor chain that pins each node to its tier rank from above and below, only
5
+ where its real edges would let it drift.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import textwrap
15
+ from pathlib import Path
16
+
17
+ from . import views
18
+ from .schema import HIERARCHY_TIERS, NodeSummary, View
19
+
20
+ NODE_W, NODE_H = 250, 110 # px; uniform for every node
21
+ FONT_FAMILY = "Source Sans 3"
22
+
23
+ TIER_LABEL = {
24
+ "complete": "Complete\nsystem",
25
+ "system": "System\ntier",
26
+ "subsystem": "Subsystem\ntier",
27
+ "benchmark": "Benchmark\ntier",
28
+ "unit": "Unit\nproblems",
29
+ }
30
+ D2_SHAPE = {
31
+ "complete": "oval",
32
+ "system": "oval",
33
+ "subsystem": "hexagon",
34
+ "benchmark": "rectangle",
35
+ "unit": "rectangle",
36
+ }
37
+ STATUS_EDGE = {
38
+ "none": "#9a9a9a",
39
+ "identified": "#F5D300",
40
+ "requested": "#FE53BB",
41
+ "in-hand": "#08F7FE",
42
+ }
43
+ LEVEL_FILL = {0: "#000000", 1: "#3a3a3a", 2: "#0b3d16", 3: "#0f6b25", 4: "#22a544"}
44
+ EVIDENCE_TEXT = {
45
+ "A": "A code verification",
46
+ "C": "C solution verification",
47
+ "B": "B cross-code benchmark",
48
+ "D": "D validation against measurement",
49
+ "E": "E uncertainty quantification",
50
+ }
51
+ LEVEL_TEXT = {
52
+ 0: "0 insufficient evidence",
53
+ 1: "1 conceptually validated",
54
+ 2: "2 representative system, in domain",
55
+ 3: "3 real system, representative environment",
56
+ 4: "4 real system, operating environment",
57
+ }
58
+
59
+
60
+ def _wrap(text: str, chars: int) -> str:
61
+ """Wrap a label to a width, joined by the D2 line break."""
62
+ return "\\n".join(textwrap.wrap(text, chars))
63
+
64
+
65
+ def _evidence_text(summary: NodeSummary) -> str:
66
+ """The evidence line for a label: demonstrated plain, claimed in parentheses."""
67
+ parts = list(summary.demonstrated)
68
+ if summary.computed:
69
+ parts += [f"({tier})" for tier in summary.claimed]
70
+ return " ".join(parts) or "none"
71
+
72
+
73
+ def emit(nodes: dict, summary: dict, view: View) -> str:
74
+ """Render the hierarchy as D2 source for one view."""
75
+ dark = view.mode == "status"
76
+ fg, bg = ("#ffffff", "#000000") if dark else ("#000000", "#ffffff")
77
+ lines = [
78
+ "direction: down",
79
+ f"vars: {{ d2-config: {{ layout-engine: {view.layout} }} }}",
80
+ f'style.fill: "{bg}"',
81
+ ]
82
+ tiers = [t for t in HIERARCHY_TIERS if any(n.tier == t for n in nodes.values())]
83
+ # Rank pinning. Anchor chain A0 -> ... -> A(k): A(i) carries the label of tier i
84
+ # and shares its rank with row i; A(k) is a hidden floor. A node is pinned from
85
+ # above (A(i-1) -> node) when no real parent sits in the tier directly above it,
86
+ # and from below (node -> A(i+1)) when no real child sits in the tier directly
87
+ # below it. Together with the real edges this fixes every node to its tier rank
88
+ # under both layout engines with the fewest invisible edges.
89
+ depth = len(tiers)
90
+ for i in range(depth + 1):
91
+ lines.append(
92
+ f'A{i}: {{ label: ""; shape: rectangle; width: 1; height: 1; '
93
+ "style.opacity: 0 }"
94
+ )
95
+ if i:
96
+ lines.append(f"A{i - 1} -> A{i}: {{ style.opacity: 0 }}")
97
+ children = {nid: [c for c in nodes if nid in nodes[c].couples_to] for nid in nodes}
98
+ highlight = set(view.highlight)
99
+ for nid, node in nodes.items():
100
+ dim = bool(highlight) and not highlight & set(summary[nid].libraries)
101
+ i = tiers.index(node.tier)
102
+ parents = [p for p in node.couples_to if p in nodes]
103
+ has_parent_above = any(tiers.index(nodes[p].tier) == i - 1 for p in parents)
104
+ has_child_below = any(
105
+ tiers.index(nodes[c].tier) == i + 1 for c in children[nid]
106
+ )
107
+ wrap = 26 if node.tier in ("benchmark", "unit") else 22
108
+ label = _wrap(node.title, wrap)
109
+ if dark and "libraries" in view.show:
110
+ label += "\\n" + _wrap(", ".join(summary[nid].libraries), 34)
111
+ if dark and "tiers" in view.show:
112
+ label += "\\nevidence " + _evidence_text(summary[nid])
113
+ fill = LEVEL_FILL[node.validation_level] if dark else bg
114
+ stroke = STATUS_EDGE[node.referent_status] if dark else fg
115
+ width = 3 if dark and node.referent_status != "none" else 2
116
+ box = (
117
+ NODE_W + 40 if node.tier in ("complete", "system", "subsystem") else NODE_W
118
+ )
119
+ lines.append(
120
+ f'{nid}: {{ label: "{label}"; shape: {D2_SHAPE[node.tier]}; '
121
+ f"width: {box}; height: {NODE_H}; "
122
+ f'style.fill: "{fill}"; style.stroke: "{stroke}"; '
123
+ f"style.stroke-width: {width}; "
124
+ f'style.font-color: "{fg}"; style.font-size: 15; style.bold: true'
125
+ + ("; style.border-radius: 8" if node.tier == "benchmark" else "")
126
+ + ("; style.opacity: 0.25" if dim else "")
127
+ + " }"
128
+ )
129
+ if i > 0 and not has_parent_above:
130
+ lines.append(f"A{i - 1} -> {nid}: {{ style.opacity: 0 }}")
131
+ if not has_child_below:
132
+ lines.append(f"{nid} -> A{i + 1}: {{ style.opacity: 0 }}")
133
+ for nid, node in nodes.items():
134
+ for parent in node.couples_to:
135
+ if parent not in nodes:
136
+ continue
137
+ dim = bool(highlight) and not (
138
+ highlight & set(summary[nid].libraries)
139
+ and highlight & set(summary[parent].libraries)
140
+ )
141
+ lines.append(
142
+ f'{parent} -> {nid}: {{ style.stroke: "{fg}"; style.stroke-width: 1'
143
+ + ("; style.opacity: 0.2" if dim else "")
144
+ + " }"
145
+ )
146
+ if dark:
147
+ present = sorted({n.validation_level for n in nodes.values()})
148
+ subtitle = (
149
+ "validation levels present: "
150
+ + ", ".join(str(v) for v in present)
151
+ + " (NASA-STD-7009B Table 9; level 2 is the ceiling before launch)"
152
+ )
153
+ head = (view.title + "\\n" if view.title else "") + subtitle
154
+ lines.insert(
155
+ 3,
156
+ f'title: {{ label: "{head}"; shape: text; near: top-center; '
157
+ f"style.font-size: {30 if view.title else 18}; "
158
+ f"style.bold: {'true' if view.title else 'false'}; "
159
+ f'style.font-color: "{fg}" }}',
160
+ )
161
+ elif view.title:
162
+ lines.insert(
163
+ 3,
164
+ f'title: {{ label: "{view.title}"; shape: text; near: top-center; '
165
+ f'style.font-size: 30; style.bold: true; style.font-color: "{fg}" }}',
166
+ )
167
+ if dark:
168
+ lines.append("legend: {")
169
+ lines.append(' label: ""')
170
+ lines.append(" near: bottom-center")
171
+ lines.append(" grid-rows: 3")
172
+ lines.append(" grid-gap: 6")
173
+ lines.append(f' style.fill: "{bg}"')
174
+ lines.append(f' style.stroke: "{bg}"')
175
+ cell = (
176
+ f"shape: rectangle; width: 300; height: 38; style.font-size: 13; "
177
+ f'style.font-color: "{fg}"'
178
+ )
179
+ # D2 fills a grid-rows grid column by column: three cells per column,
180
+ # read top to bottom
181
+ entries = []
182
+ for level, color in LEVEL_FILL.items():
183
+ entries.append(
184
+ f'l{level}: {{ label: "level {LEVEL_TEXT[level]}"; {cell}; '
185
+ f'style.fill: "{color}"; style.stroke: "{fg}"; style.stroke-width: 1 }}'
186
+ )
187
+ entries.append(f'gap1: {{ label: ""; {cell}; style.opacity: 0 }}')
188
+ for status, color in STATUS_EDGE.items():
189
+ entries.append(
190
+ f'r{status}: {{ label: "referent {status}"; {cell}; '
191
+ f'style.fill: "{bg}"; style.stroke: "{color}"; style.stroke-width: 3 }}'
192
+ )
193
+ entries.append(f'gap2: {{ label: ""; {cell}; style.opacity: 0 }}')
194
+ entries.append(f'gap3: {{ label: ""; {cell}; style.opacity: 0 }}')
195
+ for tier, text in EVIDENCE_TEXT.items():
196
+ entries.append(
197
+ f'e{tier}: {{ label: "evidence {text}"; {cell}; '
198
+ f'style.fill: "{bg}"; style.stroke: "#555555"; '
199
+ "style.stroke-width: 1; style.stroke-dash: 3 }"
200
+ )
201
+ for entry in entries:
202
+ lines.append(" " + entry)
203
+ lines.append("}")
204
+ return "\n".join(lines) + "\n"
205
+
206
+
207
+ def inject_tier_labels(svg: str, tiers: list[str], fg: str, margin: int = 230) -> str:
208
+ """Write each tier label at the left margin of its row, and widen the drawing.
209
+
210
+ Rows come from the hidden anchors ``A<i>``. The font faces are pointed at the
211
+ installed family because rsvg-convert cannot read the data-URI faces D2 embeds.
212
+ """
213
+ xs = [float(v) for v in re.findall(r'<rect x="([\d.]+)"', svg)]
214
+ xs += [float(v) for v in re.findall(r'<path d="M ([\d.]+) ', svg)]
215
+ xs += [
216
+ float(v) - float(r)
217
+ for v, r in re.findall(r'<ellipse cx="([\d.]+)" cy="[\d.]+" rx="([\d.]+)"', svg)
218
+ ]
219
+ left = min(xs) if xs else 0.0
220
+ labels = []
221
+ for i, tier in enumerate(tiers):
222
+ cls = base64.b64encode(f"A{i}".encode()).decode()
223
+ found = re.search(
224
+ r'<g class="'
225
+ + re.escape(cls)
226
+ + r'"[^>]*>.*?<rect x="([\d.]+)" y="([\d.]+)" '
227
+ r'width="[\d.]+" height="([\d.]+)"',
228
+ svg,
229
+ re.S,
230
+ )
231
+ if not found:
232
+ continue
233
+ y = float(found.group(2)) + float(found.group(3)) / 2
234
+ rows = TIER_LABEL[tier].split("\n")
235
+ x = left - 40
236
+ tspans = "".join(
237
+ f'<tspan x="{x:.1f}" dy="{0 if j == 0 else 26}">{row}</tspan>'
238
+ for j, row in enumerate(rows)
239
+ )
240
+ labels.append(
241
+ f'<text x="{x:.1f}" y="{y - 13 * (len(rows) - 1) + 8:.1f}" '
242
+ f'fill="{fg}" class="text-bold" '
243
+ f'style="text-anchor:end;font-size:22px">{tspans}</text>'
244
+ )
245
+ # geometry: outer <svg viewBox="0 0 W H"> wraps inner
246
+ # <svg width="W" height="H" viewBox="x y w h">
247
+ svg = re.sub(
248
+ r'<svg class="([^"]+)" width="([\d.]+)" height="([\d.]+)" '
249
+ r'viewBox="([-\d.]+) ([-\d.]+) ([\d.]+) ([\d.]+)">',
250
+ lambda m: (
251
+ f'<svg class="{m.group(1)}" width="{int(float(m.group(2))) + margin}" '
252
+ f'height="{m.group(3)}" '
253
+ f'viewBox="{float(m.group(4)) - margin:.0f} {m.group(5)} '
254
+ f'{float(m.group(6)) + margin:.0f} {m.group(7)}">'
255
+ ),
256
+ svg,
257
+ count=1,
258
+ )
259
+ svg = re.sub(
260
+ r'(preserveAspectRatio="xMinYMin meet" viewBox=")'
261
+ r'([-\d.]+) ([-\d.]+) ([\d.]+) ([\d.]+)"',
262
+ lambda m: (
263
+ f"{m.group(1)}{m.group(2)} {m.group(3)} "
264
+ f'{float(m.group(4)) + margin:.0f} {m.group(5)}"'
265
+ ),
266
+ svg,
267
+ count=1,
268
+ )
269
+ # background rect of the inner svg: widen too
270
+ svg = re.sub(
271
+ r'<rect x="([-\d.]+)" y="([-\d.]+)" width="([\d.]+)" height="([\d.]+)" '
272
+ r'rx="0.000000" fill="(#[0-9a-fA-F]+)" stroke-width="0" />',
273
+ lambda m: (
274
+ f'<rect x="{float(m.group(1)) - margin:.0f}" y="{m.group(2)}" '
275
+ f'width="{float(m.group(3)) + margin:.0f}" height="{m.group(4)}" '
276
+ f'rx="0" fill="{m.group(5)}" stroke-width="0" />'
277
+ ),
278
+ svg,
279
+ count=1,
280
+ )
281
+ # fonts: rsvg cannot read D2's data-URI faces; use an installed family
282
+ svg = re.sub(
283
+ r'font-family: "?d2-\d+-font-bold"?;',
284
+ f'font-family: "{FONT_FAMILY}"; font-weight: 700;',
285
+ svg,
286
+ )
287
+ svg = re.sub(
288
+ r'font-family: "?d2-\d+-font-italic"?;',
289
+ f'font-family: "{FONT_FAMILY}"; font-style: italic;',
290
+ svg,
291
+ )
292
+ svg = re.sub(
293
+ r'font-family: "?d2-\d+-font-regular"?;', f'font-family: "{FONT_FAMILY}";', svg
294
+ )
295
+ svg = re.sub(r"@font-face \{[^}]*\}", "", svg)
296
+ return svg.replace("</svg>", "".join(labels) + "</svg>", 1)
297
+
298
+
299
+ def require_binary(name: str) -> None:
300
+ """Raise when an external rendering binary is missing."""
301
+ if shutil.which(name) is None:
302
+ raise RuntimeError(
303
+ f"{name} is not on PATH; it is required to render the hierarchy"
304
+ )
305
+
306
+
307
+ def _run(command: list[str]) -> None:
308
+ """Run an external command and raise with its stderr when it fails."""
309
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
310
+ if result.returncode:
311
+ raise RuntimeError(f"{command[0]} failed: {result.stderr.strip()}")
312
+
313
+
314
+ def render(nodes, summary, view, outdir, views_dir=None):
315
+ """Write the generated D2, compile it or its override, and rasterize."""
316
+ require_binary("d2")
317
+ require_binary("rsvg-convert")
318
+ outdir = Path(outdir)
319
+ outdir.mkdir(parents=True, exist_ok=True)
320
+ generated = outdir / f"{view.name}.gen.d2"
321
+ generated.write_text(emit(nodes, summary, view))
322
+ source = views.override_path(views_dir, view.name) or generated
323
+ svg = outdir / f"{view.name}.svg"
324
+ theme = "200" if view.mode == "status" else "0"
325
+ _run(
326
+ [
327
+ "d2",
328
+ "--layout",
329
+ view.layout,
330
+ "--theme",
331
+ theme,
332
+ "--pad",
333
+ "30",
334
+ str(source),
335
+ str(svg),
336
+ ]
337
+ )
338
+ fg = "#ffffff" if view.mode == "status" else "#000000"
339
+ tiers = [t for t in HIERARCHY_TIERS if any(n.tier == t for n in nodes.values())]
340
+ svg.write_text(inject_tier_labels(svg.read_text(), tiers, fg))
341
+ png = outdir / f"{view.name}.png"
342
+ background = "black" if view.mode == "status" else "white"
343
+ _run(["rsvg-convert", "-z", "2", "-b", background, "-o", str(png), str(svg)])
344
+ return png
@@ -0,0 +1,97 @@
1
+ """Fold per-repository evidence files into one summary per hierarchy node."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .schema import EVIDENCE_ORDER, EvidenceFile, Node, NodeSummary
9
+
10
+
11
+ def load_dir(directory: Path) -> list[EvidenceFile]:
12
+ """Read every ``*.json`` evidence file in a directory."""
13
+ directory = Path(directory)
14
+ if not directory.is_dir():
15
+ return []
16
+ return [
17
+ EvidenceFile.from_dict(json.loads(path.read_text()))
18
+ for path in sorted(directory.glob("*.json"))
19
+ ]
20
+
21
+
22
+ def _order(tiers) -> tuple[str, ...]:
23
+ """Sort evidence tiers into the reading order A, C, B, D, E."""
24
+ return tuple(tier for tier in EVIDENCE_ORDER if tier in tiers)
25
+
26
+
27
+ def summarize(
28
+ nodes: dict[str, Node], files: list[EvidenceFile]
29
+ ) -> dict[str, NodeSummary]:
30
+ """One summary per node, computed where tests exist, declared where they do not."""
31
+ owner = {case: nid for nid, node in nodes.items() for case in node.cases}
32
+ collected: dict[str, list[tuple[str, object]]] = {nid: [] for nid in nodes}
33
+ for evidence_file in files:
34
+ for record in evidence_file.tests:
35
+ nid = owner.get(record.case)
36
+ if nid is not None:
37
+ collected[nid].append((evidence_file.library, record))
38
+ summaries = {}
39
+ for nid, node in nodes.items():
40
+ rows = collected[nid]
41
+ if not rows:
42
+ summaries[nid] = NodeSummary(
43
+ node_id=nid,
44
+ libraries=node.libraries_planned,
45
+ demonstrated=_order(node.evidence_declared),
46
+ computed=False,
47
+ )
48
+ continue
49
+ demonstrated: set[str] = set()
50
+ claimed: set[str] = set()
51
+ libraries: list[str] = []
52
+ srqs: list[str] = []
53
+ refs: list[str] = []
54
+ seams: list[tuple[str, str]] = []
55
+ skipped = 0
56
+ for library, record in rows:
57
+ if library not in libraries:
58
+ libraries.append(library)
59
+ if record.tier:
60
+ target = demonstrated if record.outcome == "passed" else claimed
61
+ target.add(record.tier)
62
+ if record.srq and record.srq not in srqs:
63
+ srqs.append(record.srq)
64
+ if record.ref and record.ref not in refs:
65
+ refs.append(record.ref)
66
+ if record.seam and record.seam not in seams:
67
+ seams.append(record.seam)
68
+ if record.outcome in ("skipped", "xfailed"):
69
+ skipped += 1
70
+ summaries[nid] = NodeSummary(
71
+ node_id=nid,
72
+ libraries=tuple(libraries),
73
+ demonstrated=_order(demonstrated),
74
+ claimed=_order(claimed - demonstrated),
75
+ srqs_covered=tuple(srqs),
76
+ refs=tuple(refs),
77
+ seams=tuple(seams),
78
+ tests=len(rows),
79
+ skipped=skipped,
80
+ computed=True,
81
+ )
82
+ return summaries
83
+
84
+
85
+ def unfiled_cases(
86
+ nodes: dict[str, Node], files: list[EvidenceFile]
87
+ ) -> dict[str, tuple[str, ...]]:
88
+ """Cases named by a test that no node owns, mapped to the libraries naming them."""
89
+ known = {case for node in nodes.values() for case in node.cases}
90
+ found: dict[str, list[str]] = {}
91
+ for evidence_file in files:
92
+ for record in evidence_file.tests:
93
+ if record.case and record.case not in known:
94
+ libraries = found.setdefault(record.case, [])
95
+ if evidence_file.library not in libraries:
96
+ libraries.append(evidence_file.library)
97
+ return {case: tuple(libraries) for case, libraries in sorted(found.items())}