pboProperty Ontology
python721 lines51.1 KB
RawDownload
1#!/usr/bin/env python3
2"""Generate an Ontology Semantic Atlas (documentation/semantic-atlas.md) from an ontology-project bundle.
3
4The atlas is a COMPLETE, data-driven projection: every class, property, SKOS scheme/concept, SHACL shape,
5individual, source, evidence record, decision, mapping, issue and competency question in the bundle appears.
6Nothing is hand-selected or truncated. The generator is the single authority for the atlas — improve the
7generator, never patch the Markdown.
8
9Visual grammar (see references/semantic-atlas.md §4) is centralised in N(), E(), brk() and CLASSDEFS so one
10shape + one colour per semantic category is applied identically in every diagram. Layout is always ELK
11(frontmatter on every block); readability comes from direction, grouping, declaration order and short
12labels — never from manual coordinates.
13
14Usage:
15 build_atlas.py <project-dir> [--out documentation/semantic-atlas.md] [--threads atlas-threads.yaml]
16 [--artefact-class LocalName ...] [--max-thread-edges 40] [--render-report render-report.json]
17
18 --threads optional YAML describing instance threads and their partitioned views (see reference §12);
19 without it, one thread per top-level class family is generated and threads with more
20 than --max-thread-edges assertions are partitioned automatically by predicate. A
21 configured thread is drawn whole unless it lists `views` or sets `auto_partition: true`.
22 --artefact-class root class(es) whose members are information artefacts (double-rectangle shape); default:
23 auto-detect root classes named *Artefact|*Artifact|*Document|*InformationItem.
24 --render-report JSON written by scripts/render_check.py; when given, the atlas validation section reports
25 the real renderer results instead of "not executed".
26"""
27from __future__ import annotations
28import argparse, json, re, sys
29from collections import Counter, defaultdict
30from pathlib import Path
31import yaml
32from rdflib import Graph, URIRef, Literal, Namespace
33from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD
34
35ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
36ap.add_argument("project_dir", type=Path)
37ap.add_argument("--out", type=Path, default=None)
38ap.add_argument("--threads", type=Path, default=None)
39ap.add_argument("--artefact-class", action="append", default=[])
40ap.add_argument("--max-thread-edges", type=int, default=40)
41ap.add_argument("--render-report", type=Path, default=None)
42args = ap.parse_args()
43PROJ = args.project_dir.resolve()
44OUT_PATH = (args.out if args.out and args.out.is_absolute() else PROJ / (args.out or Path("documentation/semantic-atlas.md")))
45SH = Namespace("http://www.w3.org/ns/shacl#")
46
47def yload(p, default=None):
48 p = PROJ / p
49 if not p.exists(): return default if default is not None else {}
50 return yaml.safe_load(p.read_text(encoding="utf-8")) or (default if default is not None else {})
51model = yload("model.yaml", {}); req = yload("requirements.yaml", {})
52srcs = (yload("sources.yaml", {}) or {}).get("sources") or []
53decs = (yload("decisions.yaml", {}) or {}).get("decisions") or []
54ev = [json.loads(l) for l in (PROJ / "evidence.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()] if (PROJ / "evidence.jsonl").exists() else []
55qman = ((yload("queries/manifest.yaml", {}) or {}).get("queries") or [])
56cqres = {}
57p = PROJ / "validation/cq-results.json"
58if p.exists():
59 d = json.load(open(p, encoding="utf-8"))
60 for r in d.get("results", d.get("tests", [])): cqres[r.get("cq_id")] = r
61render_report = json.load(open(args.render_report, encoding="utf-8")) if args.render_report and args.render_report.exists() else None
62
63def parse(name):
64 g = Graph(); f = PROJ / name
65 if f.exists(): g.parse(f, format="turtle")
66 return g
67O, T, S, I = parse("ontology.ttl"), parse("taxonomy.ttl"), parse("shapes.ttl"), parse("instances.ttl")
68ALL = O + T + S + I
69BASE = str(((req.get("ontology") or {}).get("base_iri")) or "")
70if not BASE: # infer: most common namespace among declared classes
71 ns = Counter(re.sub(r"[^/#]+$", "", str(c)) for c in O.subjects(RDF.type, OWL.Class) if isinstance(c, URIRef))
72 BASE = ns.most_common(1)[0][0] if ns else ""
73PFX = str((req.get("ontology") or {}).get("prefix") or "ex")
74TITLE = str((req.get("ontology") or {}).get("title") or (req.get("ontology") or {}).get("id") or "Ontology")
75VERSION = str((req.get("ontology") or {}).get("version") or "")
76
77def ln(u):
78 s = str(u)
79 if BASE and s.startswith(BASE): return s[len(BASE):]
80 return s.rsplit("#", 1)[-1].rsplit("/", 1)[-1]
81def nid(u): return "n_" + re.sub(r"[^A-Za-z0-9_]", "_", ln(u))
82def esc(s): return str(s).replace('"', "#quot;").replace("<", "&lt;").replace(">", "&gt;")
83def lab(u, g=ALL):
84 for L in g.objects(u, RDFS.label): return str(L)
85 for L in g.objects(u, SKOS.prefLabel): return str(L)
86 return ln(u)
87def resolve(iri):
88 """model.yaml IRIs may be full or prefixed (`ex:Name`); resolve prefixed forms against the base IRI."""
89 s = str(iri)
90 if re.match(r"^https?://", s): return s
91 return BASE + s.split(":", 1)[1] if ":" in s else BASE + s
92term_by_iri = {resolve(t["iri"]): t for t in (model.get("terms") or []) if t.get("iri")}
93def defn(u):
94 for c in O.objects(u, RDFS.comment): return str(c)
95 for c in T.objects(u, SKOS.definition): return str(c)
96 t = term_by_iri.get(str(u)); return str(t.get("definition") or "") if t else ""
97def local(u): return BASE and str(u).startswith(BASE)
98
99classes = sorted([c for c in O.subjects(RDF.type, OWL.Class) if isinstance(c, URIRef) and local(c)], key=ln)
100oprops = sorted([p for p in O.subjects(RDF.type, OWL.ObjectProperty) if isinstance(p, URIRef)], key=ln)
101dprops = sorted([p for p in O.subjects(RDF.type, OWL.DatatypeProperty) if isinstance(p, URIRef)], key=ln)
102schemes = sorted(T.subjects(RDF.type, SKOS.ConceptScheme), key=ln)
103concepts = sorted(T.subjects(RDF.type, SKOS.Concept), key=ln)
104shapes = sorted(S.subjects(RDF.type, SH.NodeShape), key=ln)
105parent = {c: [p_ for p_ in O.objects(c, RDFS.subClassOf) if p_ in classes] for c in classes}
106children = defaultdict(list)
107for c, ps in parent.items():
108 for p_ in ps: children[p_].append(c)
109def ancestors(c):
110 out, stack = [], list(parent.get(c, []))
111 while stack:
112 a = stack.pop()
113 if a not in out: out.append(a); stack += parent.get(a, [])
114 return out
115def descendants(c):
116 out, stack = [], list(children.get(c, []))
117 while stack:
118 a = stack.pop()
119 if a not in out: out.append(a); stack += children.get(a, [])
120 return sorted(out, key=ln)
121roots = [c for c in classes if not parent[c]]
122ART_ROOTS = {c for c in roots if ln(c) in set(args.artefact_class)} or {c for c in roots if re.search(r"(Artefact|Artifact|Document|InformationItem)$", ln(c))}
123def is_art(c): return c in ART_ROOTS or any(a in ART_ROOTS for a in ancestors(c))
124dom = {p_: [d for d in O.objects(p_, RDFS.domain)] for p_ in oprops + dprops}
125rng = {p_: [r for r in O.objects(p_, RDFS.range)] for p_ in oprops + dprops}
126ev_by_id = {e["id"]: e for e in ev if e.get("id")}
127instances_of = defaultdict(list)
128for s_, o_ in I.subject_objects(RDF.type):
129 if o_ in classes: instances_of[o_].append(s_)
130for k in instances_of: instances_of[k].sort(key=ln)
131individuals = sorted({s_ for s_ in I.subjects(RDF.type) if isinstance(s_, URIRef)}, key=ln)
132def types_of(n): return [t for t in I.objects(n, RDF.type) if t in classes]
133def primary_type(n):
134 ts = types_of(n); return ts[0] if ts else None
135concept_scheme = {c: list(T.objects(c, SKOS.inScheme)) for c in concepts}
136prop_schemes = defaultdict(set) # schemes each SKOS-valued property actually consumes (from instance data)
137for p_ in oprops:
138 for _, o_ in I.subject_objects(p_):
139 for sc in concept_scheme.get(o_, []): prop_schemes[p_].add(sc)
140# SHACL rows: (shape, targetClass, path, min, max, kind, allowed values, message)
141shape_rows = []
142for sh in shapes:
143 tc = list(S.objects(sh, SH.targetClass))
144 for ps in S.objects(sh, SH.property):
145 path = list(S.objects(ps, SH.path)); mn = list(S.objects(ps, SH.minCount)); mx = list(S.objects(ps, SH.maxCount))
146 dt = list(S.objects(ps, SH.datatype)); cl = list(S.objects(ps, SH["class"])); msg = list(S.objects(ps, SH.message))
147 inl = list(S.objects(ps, SH["in"])); vals = []
148 if inl:
149 node = inl[0]
150 while node != RDF.nil:
151 vals.append(ln(list(S.objects(node, RDF.first))[0])); node = list(S.objects(node, RDF.rest))[0]
152 kind = f"xsd:{ln(dt[0])}" if dt else (f"class {ln(cl[0])}" if cl else "")
153 shape_rows.append((sh, tc[0] if tc else None, path[0] if path else None, str(mn[0]) if mn else "", str(mx[0]) if mx else "", kind, ", ".join(vals), str(msg[0]) if msg else ""))
154 for sp in S.objects(sh, SH.sparql):
155 msg = list(S.objects(sp, SH.message))
156 shape_rows.append((sh, tc[0] if tc else None, None, "", "", "SPARQL constraint", "", str(msg[0]) if msg else ""))
157shape_by_class = defaultdict(list)
158for r in shape_rows: shape_by_class[r[1]].append(r)
159cqs = req.get("competency_questions") or []
160cq_text = {}
161for qq in qman:
162 f = PROJ / "queries" / str(qq.get("file", ""))
163 if qq.get("cq_id") and f.exists(): cq_text[qq["cq_id"]] = f.read_text(encoding="utf-8")
164def mentioned(name, txt): return re.search(r"[:/#]" + re.escape(name) + r"\b", txt) is not None # prefix-agnostic
165cq_touch = defaultdict(set)
166for cid, txt in cq_text.items():
167 for c in classes:
168 if mentioned(ln(c), txt): cq_touch[c].add(cid)
169 for p_ in oprops + dprops:
170 if mentioned(ln(p_), txt): cq_touch[p_].add(cid)
171 for s_ in individuals:
172 if mentioned(ln(s_), txt):
173 for t in types_of(s_): cq_touch[t].add(cid)
174
175# ----------------------------------------------------------------------------- Mermaid helpers (single visual grammar)
176OUT = []
177SECTIONS = []
178def w(s=""): OUT.append(s)
179def section(title):
180 SECTIONS.append(title); w(f"## {len(SECTIONS)}. {title}"); w(); return len(SECTIONS)
181FRONTMATTER = ["---", "config:", " layout: elk", "---"]
182# One colour per semantic category — a redundant cue only; every distinction is also carried by node shape
183# and edge style, so the diagrams remain readable in monochrome.
184CLASSDEFS = [
185 " classDef owl fill:#e3eefc,stroke:#2f5d9e,color:#111",
186 " classDef focal fill:#e3eefc,stroke:#1a3c6e,stroke-width:3px,color:#111",
187 " classDef skos fill:#fff3c4,stroke:#9a7b00,color:#111",
188 " classDef shacl fill:#fde2e1,stroke:#a33333,stroke-dasharray:4 2,color:#111",
189 " classDef fix fill:#f0f0f0,stroke:#777,color:#222",
190 " classDef ext fill:#ffffff,stroke:#555,stroke-dasharray:2 2,color:#111",
191 " classDef prov fill:#f7f7f7,stroke:#999,color:#333",
192]
193SHAPE = {"owl": ('["', '"]'), "art": ('[["', '"]]'), "focal": ('["', '"]'), "focalart": ('[["', '"]]'),
194 "skos": ('(["', '"])'), "shacl": ('("', '")'), "fix": ('["', '"]'), "fixart": ('[["', '"]]'),
195 "ext": ('{{"', '"}}'), "prov": ('["', '"]')}
196STYLE = {"owl": "owl", "art": "owl", "focal": "focal", "focalart": "focal", "skos": "skos", "shacl": "shacl",
197 "fix": "fix", "fixart": "fix", "ext": "ext", "prov": "prov"}
198def N(i, text, kind):
199 """Node declaration: id, already-escaped label, semantic category."""
200 o, c = SHAPE[kind]; return f" {i}{o}{text}{c}:::{STYLE[kind]}"
201def class_node(i, cls, focal=False):
202 k = ("focalart" if focal else "art") if is_art(cls) else ("focal" if focal else "owl")
203 return N(i, esc(ln(cls)), k)
204def ind_node(i, ind, with_type=True):
205 t = primary_type(ind)
206 txt = brk(lab(ind)) + (f"<br/><i>{esc(ln(t))}</i>" if with_type and t is not None else "")
207 return N(i, txt, "fixart" if (t is not None and is_art(t)) else "fix")
208def E(a, b, label="", dashed=False):
209 lab_ = f'|"{label}"|' if label else ""
210 return f" {a} {'-.->' if dashed else '-->'}{lab_} {b}"
211def brk(s, width=20):
212 """Short display label: deterministic <br/> breaks at phrase boundaries (before a parenthesis, after an
213 em-dash / ';' / ':') and otherwise at the last space before `width` characters (kept under Mermaid's own
214 200 px wrap so the generator's breaks are the binding ones). The verbose literal lives in the adjacent table."""
215 s = str(s)
216 if len(s) <= width: return esc(s)
217 lines, cur = [], ""
218 for wd in s.split(" "):
219 boundary = wd.startswith("(") or (cur.endswith(("—", ";", ":")) and cur)
220 if cur and (len(cur) + 1 + len(wd) > width or boundary): lines.append(cur); cur = wd
221 else: cur = (cur + " " + wd) if cur else wd
222 if cur: lines.append(cur)
223 return "<br/>".join(esc(x) for x in lines)
224def mm(lines):
225 """Emit a fenced Mermaid block. EVERY block starts with the ELK-layout config frontmatter (the `---`
226 header must be the first lines inside the fence, before the diagram type line). Flowcharts get the
227 shared classDefs so the same semantic category always has the same presentation."""
228 w("```mermaid"); OUT.extend(FRONTMATTER); OUT.extend(lines)
229 if lines and lines[0].startswith("flowchart"): OUT.extend(CLASSDEFS)
230 w("```"); w()
231def card(mn, mx):
232 return f" [{mn or '0'}..{mx or '*'} SHACL]" if (mn or mx) else ""
233def card_short(mn, mx):
234 return " · ".join(x for x in ((f"min {mn}" if mn else ""), (f"max {mx}" if mx else "")) if x)
235def constraint_label(r):
236 """Concise SHACL constraint label (path / min / max / datatype / class / allowed values)."""
237 if not r[2]: return "SPARQL constraint"
238 bits = [x for x in (card_short(r[3], r[4]), r[5]) if x]
239 if r[6]: bits.append("in {" + r[6] + "}")
240 return esc(ln(r[2])) + ("<br/>" + esc(" · ".join(bits)) if bits else "")
241def subj_types(p_): return sorted({t for s_ in I.subjects(p_, None) for t in types_of(s_)}, key=ln)
242def obj_types(p_): return sorted({t for _, o_ in I.subject_objects(p_) for t in types_of(o_)}, key=ln)
243def id_range(ids):
244 ids = sorted(str(x) for x in ids); return f"{ids[0]}..{ids[-1]}" if ids else "—"
245n_terms = len(model.get("terms") or []); n_ax = len(model.get("axioms") or []); n_map = len(model.get("mappings") or [])
246n_pat = len(model.get("patterns") or []); n_iss = len(model.get("unresolved_issues") or [])
247
248# ----------------------------------------------------------------------------- header
249w(f"# Ontology Semantic Atlas — {TITLE}" + (f" ({VERSION})" if VERSION else ""))
250w()
251w("**Complete, data-driven projection** of the project bundle. Every diagram, register and table in this document is generated by `scripts/build_atlas.py` directly from `ontology.ttl`, `taxonomy.ttl`, `shapes.ttl`, `instances.ttl`, `model.yaml`, `requirements.yaml`, `sources.yaml`, `evidence.jsonl`, `decisions.yaml`, `queries/` and `validation/cq-results.json` — nothing is hand-selected, truncated or summarised. The atlas documents the ontology; it adds no axioms and does not change release state.")
252w()
253w("@@CONTENTS@@")
254w()
255
256# ----------------------------------------------------------------------------- inventory
257section("Inventory")
258w("| Layer | Count |"); w("|---|---|")
259w(f"| OWL classes | {len(classes)} |"); w(f"| Object properties | {len(oprops)} |"); w(f"| Datatype properties | {len(dprops)} |")
260w(f"| rdfs:subClassOf axioms | {sum(len(v) for v in parent.values())} |")
261w(f"| SKOS concept schemes / concepts | {len(schemes)} / {len(concepts)} |")
262w(f"| SHACL node shapes / constraints | {len(shapes)} / {len(shape_rows)} |")
263w(f"| Individuals in instances.ttl / triples | {len(individuals)} / {len(I)} |")
264w(f"| Competency questions (core) | {len(cqs)} ({sum(1 for c in cqs if c.get('priority')=='core')}) |")
265w(f"| Sources / evidence records / decisions | {len(srcs)} / {len(ev)} / {len(decs)} |")
266w(f"| Model terms / axioms / mappings / patterns / open issues | {n_terms} / {n_ax} / {n_map} / {n_pat} / {n_iss} |")
267w()
268
269# ----------------------------------------------------------------------------- grammar
270section("Visual grammar")
271w("One shape and one edge style per semantic category, applied identically in every diagram. Colour is a secondary, redundant cue only — every distinction is also carried by shape and edge style, so the diagrams read correctly in monochrome. Colour is **not** part of the ontology semantics.")
272w()
273w("| Device | Meaning |"); w("|---|---|")
274w("| Rectangle `[ ]` (pale blue) | OWL/RDFS class |")
275w("| Rectangle `[ ]` with heavy outline | The class being profiled (focal class) |")
276w("| Double rectangle `[[ ]]` | Information-artefact class or instance" + (f" (family: {', '.join('`'+ln(a)+'`' for a in sorted(ART_ROOTS, key=ln))})" if ART_ROOTS else "") + " |")
277w("| Rectangle `[ ]` (neutral grey) | Fixture individual from `instances.ttl` — example data, not an ontology concept |")
278w("| Stadium `([ ])` (pale gold) | SKOS concept scheme or SKOS concept (controlled value) |")
279w("| **Rounded rectangle `( )` (pale red, dashed border)** | **SHACL shape or SHACL constraint — always connected by a dashed edge and never treated as an OWL node** |")
280w("| Hexagon `{{ }}` | Reused external class — candidate mapping only |")
281w("| Solid arrow `-->` | Explicit object property assertion or `rdfs:subClassOf` |")
282w("| Dashed arrow `-.->` | Provenance/evidence trace, SHACL overlay, `rdf:type` of a fixture, documentation lens, or interpretive sequence — **not** an OWL predicate |")
283w("| `✱` on an edge label | Polymorphic property (no declared domain/range); the end shown is derived from asserted types in `instances.ttl` |")
284w("| `[min..max SHACL]` / `min n · max n` | SHACL cardinality from `shapes.ttl` — **not an OWL** cardinality restriction |")
285w("| `CQ-nnn` `EV-nnn` `SRC-nnn` `DEC-nnn` `ISS-nnn` | competency-question / evidence / source / decision / issue trace |")
286w()
287w("Labels: class nodes carry the ontology local name only; fixture nodes carry a short display label plus their type. Definitions, comments, evidence text and long literals live in the adjacent tables, never inside graph nodes. SKOS concepts are controlled values reached through classification properties; they are **not** OWL subclasses. SHACL constraints are closed-world operational rules; they are **not OWL** semantics.")
288w()
289
290# ----------------------------------------------------------------------------- architecture
291section("Semantic architecture")
292src_ids = [s.get("id") for s in srcs if s.get("id")]; ev_ids = [e.get("id") for e in ev if e.get("id")]; dec_ids = [d.get("id") for d in decs if d.get("id")]
293mm(["flowchart TB",
294 N("REQ", f"requirements.yaml<br/>scope · {len(cqs)} CQs · release policy", "prov"),
295 N("SRC", f"sources.yaml<br/>{len(srcs)} sources {id_range(src_ids)}", "prov"),
296 N("EV", f"evidence.jsonl<br/>{len(ev)} records {id_range(ev_ids)}", "prov"),
297 N("DEC", f"decisions.yaml<br/>{len(decs)} decisions {id_range(dec_ids)}", "prov"),
298 N("MOD", f"model.yaml<br/>{n_terms} terms · {n_ax} axioms · {n_map} mappings · {n_iss} issues", "prov"),
299 N("OWL", f"ontology.ttl<br/>{len(classes)} classes · {len(oprops)} object props · {len(dprops)} datatype props", "owl"),
300 N("TAX", f"taxonomy.ttl<br/>{len(schemes)} SKOS schemes · {len(concepts)} concepts", "skos"),
301 N("SHP", f"shapes.ttl<br/>{len(shapes)} SHACL node shapes", "shacl"),
302 N("INST", f"instances.ttl<br/>{len(individuals)} individuals · {len(I)} triples", "fix"),
303 N("CQ", f"queries/<br/>{len(qman)} SPARQL tests", "prov"),
304 N("VAL", "validation/<br/>project · CQ · SHACL · atlas reports", "prov"),
305 E("SRC", "EV", "cited by", True), E("EV", "MOD", "supports", True), E("DEC", "MOD", "constrains", True), E("REQ", "MOD", "governs", True),
306 E("MOD", "OWL", "formalised as"), E("MOD", "TAX", "formalised as"), E("MOD", "SHP", "informs", True),
307 E("OWL", "INST"), E("TAX", "INST"), E("SHP", "INST", "validates", True), E("INST", "CQ", "answered by", True),
308 E("CQ", "VAL", "results", True), E("SHP", "VAL", "results", True), E("OWL", "VAL", "results", True)])
309
310# ----------------------------------------------------------------------------- master map (two projections)
311sec_master = section("Master connected ontology map (4A hierarchy · 4B property topology)")
312w("The master map is drawn as **two complementary projections of the same complete model**: A shows every class and every `rdfs:subClassOf` axiom and nothing else; B shows the same class universe with the subclass edges removed and every class-to-class object property drawn instead. Neither view omits anything from its own semantic layer.")
313w()
314w(f"### {sec_master}A. Formal class hierarchy — all {len(classes)} classes, all {sum(len(v) for v in parent.values())} `rdfs:subClassOf` axioms")
315w()
316w("Every edge is `rdfs:subClassOf` (child rises toward its parent). Top-level classes are the roots. Nothing else is drawn here.")
317w()
318lines = ["flowchart BT"] + [class_node(nid(c), c) for c in classes]
319for c in classes:
320 for p_ in parent[c]: lines.append(E(nid(c), nid(p_)))
321mm(lines)
322w(f"### {sec_master}B. Object-property topology — all class-to-class object properties")
323w()
324w("Classes are grouped by their top-level OWL parent (real `rdfs:subClassOf` families, drawn as groups so the subclass edges themselves are not repeated). Edges are the declared object properties whose domain and range are both classes; polymorphic properties without a declared domain or range are drawn from the asserted subject/object types in `instances.ttl` and marked ✱. Properties whose range is a SKOS concept appear in the SKOS section instead.")
325w()
326lines = ["flowchart LR"]
327for r in roots:
328 kids = descendants(r)
329 if kids:
330 lines.append(f' subgraph G_{ln(r)}["{esc(lab(r))} and subclasses"]')
331 lines.append(" " + class_node(nid(r), r))
332 for k in kids: lines.append(" " + class_node(nid(k), k))
333 lines.append(" end")
334 else: lines.append(class_node(nid(r), r))
335drawn = set()
336for p_ in oprops:
337 d_, r_ = dom[p_], rng[p_]
338 if r_ and r_[0] == SKOS.Concept: continue
339 subs = [d for d in d_ if d in classes] or subj_types(p_)
340 objs = [r for r in r_ if r in classes] or obj_types(p_)
341 poly = "" if (d_ and r_) else " ✱"
342 for s_ in subs:
343 for o_ in objs:
344 key = (nid(s_), ln(p_), nid(o_))
345 if key not in drawn: drawn.add(key); lines.append(E(nid(s_), nid(o_), ln(p_) + poly))
346mm(lines)
347
348# ----------------------------------------------------------------------------- class profiles
349sec_prof = section("Class semantic profiles — all classes")
350w("For every class: definition, formal parents/ancestors/subclasses, the datatype surface (declared here or inherited), outgoing and incoming object properties, SKOS bindings, SHACL constraints, evidence and decisions, every individual of the class in `instances.ttl`, and the competency questions that traverse it. Every profile diagram follows one template: incoming relations on the left → the **focal class** (heavy outline) → outgoing OWL targets → SKOS schemes (stadiums) → SHACL constraints (rounded, dashed) → the `Fixture individuals` group (grey, dashed `rdf:type`). A diagram is drawn for each class that has any relations or instances.")
351w()
352for c in classes:
353 t = term_by_iri.get(str(c), {})
354 w(f"### {sec_prof}.{classes.index(c)+1} `{PFX}:{ln(c)}` — {lab(c)}"); w()
355 w(f"**Definition.** {defn(c) or '—'}"); w()
356 anc = ancestors(c); desc_ = descendants(c)
357 w(f"- **Parents:** {', '.join('`'+ln(x)+'`' for x in parent[c]) or '— (top-level)'} · **Ancestors:** {', '.join('`'+ln(x)+'`' for x in anc) or '—'} · **Subclasses:** {', '.join('`'+ln(x)+'`' for x in desc_) or '—'}")
358 w(f"- **Model term:** {t.get('id','—')} · support `{t.get('support','—')}` · evidence {', '.join(t.get('evidence') or []) or '—'}" + (f" · rationale: {t['rationale']}" if t.get('rationale') else ""))
359 lineage = [c] + anc
360 dsurf = [p_ for p_ in dprops if any(d in lineage for d in dom[p_])]
361 dsurf_poly = [p_ for p_ in dprops if not dom[p_] and any((s_, p_, None) in I for s_ in instances_of.get(c, []))]
362 outs = [p_ for p_ in oprops if any(d in lineage for d in dom[p_])]
363 outs_poly = sorted({p_ for p_ in oprops if not dom[p_] for s_ in instances_of.get(c, []) if (s_, p_, None) in I}, key=ln)
364 ins = [p_ for p_ in oprops if any(r in lineage for r in rng[p_])]
365 ins_poly = sorted({p_ for p_ in oprops if (not rng[p_] or rng[p_][0] not in classes) for o_ in instances_of.get(c, []) if (None, p_, o_) in I}, key=ln)
366 def fmt(ps, inh=True):
367 return ", ".join(f"`{ln(p_)}`" + ("" if (not inh) or any(d == c for d in dom.get(p_, [])) else " (inherited)") for p_ in ps) or "—"
368 w(f"- **Datatype surface:** {fmt(dsurf)}" + (f" · used polymorphically: {fmt(dsurf_poly, False)}" if dsurf_poly else ""))
369 w(f"- **Outgoing object properties:** {fmt(outs)}" + (f" · polymorphic (no declared domain, asserted on this class's instances): {fmt(outs_poly, False)}" if outs_poly else ""))
370 w(f"- **Incoming object properties:** {', '.join('`'+ln(p_)+'`' for p_ in ins) or '—'}" + (f" · polymorphic: {', '.join('`'+ln(p_)+'`' for p_ in ins_poly)}" if ins_poly else ""))
371 skb = [(p_, sorted(prop_schemes[p_], key=ln)) for p_ in outs + outs_poly if prop_schemes.get(p_)]
372 w("- **SKOS bindings:** " + ("; ".join(f"`{ln(p_)}` → " + ", ".join('`'+ln(s)+'`' for s in ss) for p_, ss in skb) or "—"))
373 shp = [r for cc in lineage for r in shape_by_class.get(cc, [])]
374 w("- **SHACL:** " + ("; ".join(f"`{ln(r[0])}`: " + (f"`{ln(r[2])}`{card(r[3], r[4])}" if r[2] else r[5]) + (f" {r[5]}" if r[2] and r[5] else "") + (f" in {{{r[6]}}}" if r[6] else "") for r in shp) or "— (no shape targets this class)"))
375 decs_c = [d["id"] for d in decs if d.get("id") and (ln(c) in json.dumps(d) or (t.get("id") and t["id"] in json.dumps(d)))]
376 w(f"- **Decisions:** {', '.join(decs_c) or '—'}")
377 insts = instances_of.get(c, [])
378 w(f"- **Individuals ({len(insts)}):** " + (", ".join(f"`{ln(i)}`" for i in insts) or "— (none in fixtures)"))
379 cqc = sorted(cq_touch.get(c, set()) | {cid for p_ in outs + ins for cid in cq_touch.get(p_, set())})
380 w(f"- **Competency questions:** {', '.join(cqc) or '—'}"); w()
381 if outs or outs_poly or ins or ins_poly or insts:
382 in_nodes, in_edges, out_nodes, out_edges, sk_nodes, sk_edges, sh_nodes, sh_edges = [], [], [], [], [], [], [], []
383 seen = set()
384 for p_ in ins + ins_poly:
385 for sc in ([d for d in dom[p_] if d in classes] or subj_types(p_)):
386 k = ("i", ln(p_), ln(sc))
387 if k in seen or sc == c: continue
388 seen.add(k)
389 if f"I_{nid(sc)}" not in {x[0] for x in in_nodes}: in_nodes.append((f"I_{nid(sc)}", sc))
390 in_edges.append(E(f"I_{nid(sc)}", "C", ln(p_) + ("" if dom[p_] else " ✱")))
391 for p_ in outs + outs_poly:
392 tgt = rng[p_][0] if rng[p_] else None
393 if tgt is not None and tgt == SKOS.Concept:
394 for s in sorted(prop_schemes.get(p_, []), key=ln):
395 k = ("o", ln(p_), ln(s))
396 if k in seen: continue
397 seen.add(k)
398 if nid(s) not in {x[0] for x in sk_nodes}: sk_nodes.append((nid(s), s))
399 sk_edges.append(E("C", nid(s), ln(p_)))
400 else:
401 targets = [tgt] if tgt in classes else sorted({t for s_ in instances_of.get(c, []) for o_ in I.objects(s_, p_) for t in types_of(o_)}, key=ln)
402 if not targets: targets = obj_types(p_)
403 for tg in targets:
404 k = ("o", ln(p_), ln(tg))
405 if k in seen: continue
406 seen.add(k)
407 if f"O_{nid(tg)}" not in {x[0] for x in out_nodes}: out_nodes.append((f"O_{nid(tg)}", tg))
408 out_edges.append(E("C", f"O_{nid(tg)}", ln(p_) + ("" if rng[p_] else " ✱")))
409 for j, r in enumerate(shp):
410 sid = f"SH_{nid(r[0])}_{j}"
411 sh_nodes.append(N(sid, f"{esc(ln(r[0]))}<br/>{constraint_label(r)}", "shacl")); sh_edges.append(E("C", sid, "", True))
412 L = ["flowchart LR"]
413 if in_nodes:
414 L.append(' subgraph IN["Incoming"]'); L += [" " + class_node(i_, sc) for i_, sc in in_nodes]; L.append(" end")
415 surface = "<br/>".join(f"{esc(ln(p_))} : {esc(ln(rng[p_][0])) if rng[p_] else 'literal'}" for p_ in (dsurf + dsurf_poly))
416 L.append(N("C", f"<b>{esc(ln(c))}</b>" + (f"<br/>{surface}" if surface else ""), "focalart" if is_art(c) else "focal"))
417 if out_nodes:
418 L.append(' subgraph OUTG["Outgoing targets"]'); L += [" " + class_node(i_, tg) for i_, tg in out_nodes]; L.append(" end")
419 if sk_nodes:
420 L.append(' subgraph SK["SKOS schemes"]'); L += [" " + N(i_, esc(ln(s)), "skos") for i_, s in sk_nodes]; L.append(" end")
421 if sh_nodes:
422 L.append(' subgraph SHG["SHACL (operational, not OWL)"]'); L += [" " + x for x in sh_nodes]; L.append(" end")
423 if insts:
424 L.append(f' subgraph FX["Fixture individuals ({len(insts)})"]'); L += [" " + ind_node(f"F_{nid(ind)}", ind, with_type=False) for ind in insts]; L.append(" end")
425 L += in_edges + out_edges + sk_edges + sh_edges + [E(f"F_{nid(ind)}", "C", "rdf:type", True) for ind in insts]
426 mm(L)
427
428# ----------------------------------------------------------------------------- property registers
429section("Object-property register")
430w("| Property | Domain | Range | Uses in fixtures | Consumed SKOS schemes | CQs | Definition | Term · support · evidence |"); w("|---|---|---|---|---|---|---|---|")
431for p_ in oprops:
432 t = term_by_iri.get(str(p_), {}); n = sum(1 for _ in I.triples((None, p_, None)))
433 w(f"| `{ln(p_)}` | {ln(dom[p_][0]) if dom[p_] else '— (polymorphic)'} | {ln(rng[p_][0]) if rng[p_] else '— (polymorphic)'} | {n} | {', '.join(ln(s) for s in sorted(prop_schemes.get(p_, []), key=ln)) or '—'} | {', '.join(sorted(cq_touch.get(p_, []))) or '—'} | {defn(p_)} | {t.get('id','—')} · {t.get('support','—')} · {', '.join(t.get('evidence') or [])} |")
434w()
435section("Datatype-property register")
436w("| Property | Domain | Range | Uses in fixtures | SHACL | Definition | Term · support · evidence |"); w("|---|---|---|---|---|---|---|")
437for p_ in dprops:
438 t = term_by_iri.get(str(p_), {}); n = sum(1 for _ in I.triples((None, p_, None)))
439 sh = "; ".join(f"{ln(r[0])}{card(r[3], r[4])} {r[5]}" for r in shape_rows if r[2] == p_) or "—"
440 w(f"| `{ln(p_)}` | {ln(dom[p_][0]) if dom[p_] else '— (polymorphic)'} | {ln(rng[p_][0]) if rng[p_] else '—'} | {n} | {sh} | {defn(p_)} | {t.get('id','—')} · {t.get('support','—')} · {', '.join(t.get('evidence') or [])} |")
441w()
442
443# ----------------------------------------------------------------------------- SKOS
444if schemes:
445 section("SKOS vocabularies — overview, one view per scheme, all concepts")
446 w(f"**Overview:** the {len(schemes)} concept schemes and the object properties that consume them (edge = property, source = the property's domain class, ✱ = polymorphic subject). One compact view per scheme follows, each showing that scheme and **all** of its concepts; the tables carry definitions and every referencing individual.")
447 w()
448 L = ["flowchart LR"] + [N(nid(s), f"<b>{esc(ln(s))}</b>", "skos") for s in schemes]
449 declared = set()
450 for p_, ss in sorted(prop_schemes.items(), key=lambda kv: ln(kv[0])):
451 for s in sorted(ss, key=ln):
452 srcs_ = [d for d in dom[p_] if d in classes]
453 for st in (srcs_ or subj_types(p_)):
454 src_id = f"P_{nid(st)}"
455 if src_id not in declared: declared.add(src_id); L.append(class_node(src_id, st))
456 L.append(E(src_id, nid(s), ln(p_) + ("" if srcs_ else " ✱")))
457 mm(L)
458 for s in schemes:
459 w(f"### `{ln(s)}` — {lab(s, T)}"); w(); w(defn(s) or "—"); w()
460 cons = [p_ for p_, ss in prop_schemes.items() if s in ss]
461 w(f"Consumed by: {', '.join('`'+ln(p_)+'`' for p_ in sorted(cons, key=ln)) or '— (declared, not yet consumed in fixtures)'}"); w()
462 cs = [c for c in concepts if s in concept_scheme[c]]
463 L = ["flowchart LR", N(nid(s), f"<b>{esc(ln(s))}</b>", "skos")] + [N(nid(c), esc(lab(c, T)), "skos") for c in cs]
464 L += [E(nid(c), nid(s), "topConceptOf" if (c, SKOS.topConceptOf, s) in T else "inScheme") for c in cs]
465 for c in cs:
466 for b in T.objects(c, SKOS.broader):
467 if b in cs: L.append(E(nid(c), nid(b), "broader"))
468 mm(L)
469 w("| Concept | prefLabel | Definition | Top concept | Referenced by individuals | Term · evidence |"); w("|---|---|---|---|---|---|")
470 for c in cs:
471 t = term_by_iri.get(str(c), {}); refs = sorted({ln(x) for x in I.subjects(None, c)})
472 w(f"| `{ln(c)}` | {lab(c, T)} | {defn(c)} | {'yes' if (c, SKOS.topConceptOf, s) in T else 'no'} | {', '.join(refs) or '—'} | {t.get('id','—')} · {', '.join(t.get('evidence') or [])} |")
473 w()
474
475# ----------------------------------------------------------------------------- SHACL
476if shapes:
477 section("SHACL overlay — one unit per shape (operational, not OWL)")
478 w(f"One unit per shape: **shape → target class → property / SPARQL constraints**. SHACL nodes are rounded rectangles on dashed edges; the target class is the only OWL node in each unit. `min n · max n` are SHACL cardinalities — closed-world operational constraints, **not** OWL cardinality restrictions. The full constraint table (including every `sh:message`) follows the {len(shapes)} units.")
479 w()
480 for sh in shapes:
481 tc = list(S.objects(sh, SH.targetClass))
482 w(f"#### `{ln(sh)}`" + (f" → `{ln(tc[0])}`" if tc else "")); w()
483 L = ["flowchart LR", N(nid(sh), esc(ln(sh)), "shacl")]
484 if tc: L.append(class_node(nid(tc[0]), tc[0]) if tc[0] in classes else N(nid(tc[0]), esc(ln(tc[0])), "owl")); L.append(E(nid(sh), nid(tc[0]), "targetClass", True))
485 for j, r in enumerate([r for r in shape_rows if r[0] == sh]):
486 cid_ = f"{nid(sh)}_c{j}"
487 L.append(N(cid_, constraint_label(r), "shacl")); L.append(E(nid(tc[0]) if tc else nid(sh), cid_, "sh:property" if r[2] else "sh:sparql", True))
488 mm(L)
489 w("| Shape | Target class | Path | min | max | datatype/class | allowed values | message |"); w("|---|---|---|---|---|---|---|---|")
490 for r in shape_rows: w(f"| `{ln(r[0])}` | {ln(r[1]) if r[1] else ''} | {('`'+ln(r[2])+'`') if r[2] else '(SPARQL)'} | {r[3]} | {r[4]} | {r[5]} | {r[6]} | {r[7]} |")
491 w()
492 shr = PROJ / "validation/shacl-results.json"
493 if shr.exists():
494 try: w(f"SHACL executed with `pyshacl`: **conforms = {json.load(open(shr, encoding='utf-8')).get('conforms')}** (`validation/shacl-results.json`).")
495 except Exception: pass
496 else: w("SHACL conformance: not executed (no `validation/shacl-results.json`).")
497 w()
498
499# ----------------------------------------------------------------------------- instance threads
500def thread_edges(nodes):
501 return [(n, p_, o_) for n in nodes for p_, o_ in I.predicate_objects(n) if p_ in oprops and isinstance(o_, URIRef)]
502def draw_thread_view(nodes, edges, all_nodes=True):
503 L = ["flowchart LR"]; used = {e[0] for e in edges} | {e[2] for e in edges}; declared = set()
504 for n in nodes:
505 if all_nodes or n in used: declared.add(n); L.append(ind_node(nid(n), n))
506 for s_, p_, o_ in edges:
507 if o_ not in declared:
508 declared.add(o_); L.append(N(nid(o_), esc(lab(o_, T)), "skos") if o_ in concepts else ind_node(nid(o_), o_))
509 return L + [E(nid(s_), nid(o_), ln(p_)) for s_, p_, o_ in edges]
510def auto_views(edges, limit):
511 """Deterministic predicate bucketing: predicates by usage (desc, then name) greedily packed into views of ≤ limit edges."""
512 cnt = Counter(ln(e[1]) for e in edges); preds = sorted(cnt, key=lambda k: (-cnt[k], k))
513 views, cur, cur_n = [], [], 0
514 for pr in preds:
515 if cur and cur_n + cnt[pr] > limit: views.append(cur); cur, cur_n = [], 0
516 cur.append(pr); cur_n += cnt[pr]
517 if cur: views.append(cur)
518 return [(f"view {i+1} of {len(views)}", "Partition by predicate (automatic, size-bounded).", v) for i, v in enumerate(views)]
519def thread(sec_no, idx, title, cls_list, note="", views=None, auto=True):
520 nodes = sorted({n for c in cls_list for n in instances_of.get(c, [])}, key=ln)
521 if not nodes: return
522 edges = thread_edges(nodes)
523 w(f"### {sec_no}.{idx} {title}"); w()
524 if note: w(note); w()
525 if views is None and auto and args.max_thread_edges > 0 and len(edges) > args.max_thread_edges: views = auto_views(edges, args.max_thread_edges)
526 if not views: mm(draw_thread_view(nodes, edges))
527 else:
528 covered = set()
529 for k, (vt, vnote, preds) in enumerate(views):
530 ve = [e for e in edges if ln(e[1]) in set(preds)]; covered |= set(ve)
531 w(f"#### {sec_no}.{idx}{chr(97+k)} {vt}"); w()
532 w((vnote + " " if vnote else "") + f"Predicates: {', '.join('`'+p+'`' for p in preds)}." + (" All individuals of the thread are shown." if k == 0 else " Only individuals participating in these assertions are shown.")); w()
533 mm(draw_thread_view(nodes, ve, all_nodes=(k == 0)))
534 missing = set(edges) - covered
535 assert not missing, f"thread '{title}': assertions not covered by any view: {[(ln(a), ln(b), ln(c_)) for a, b, c_ in missing]}"
536 w("| Individual | Type | Literal properties |"); w("|---|---|---|")
537 for n in nodes:
538 lits = "; ".join(f"{ln(p_)} = {str(o_)}" for p_, o_ in I.predicate_objects(n) if isinstance(o_, Literal) and p_ != RDFS.label)
539 w(f"| `{ln(n)}` | {', '.join(ln(t) for t in I.objects(n, RDF.type))} | {lits or '—'} |")
540 w()
541if individuals:
542 sec_thr = section("Instance threads — every individual")
543 w("Each thread draws **all** individuals of the listed classes and **all** object-property assertions among them (fixture data — examples, not universal rules). Node label = short display name + type; literal values are in the table after each thread. Where a thread is dense it is partitioned into views: the first view enumerates every individual, each view is generated from the same fixture triples, a node reused across views keeps exactly the same label and styling, and the union of the views contains every assertion (checked by the generator).")
544 w()
545 cfg = yaml.safe_load(args.threads.read_text(encoding="utf-8")) if args.threads and args.threads.exists() else None
546 if cfg and cfg.get("threads"):
547 for i, th in enumerate(cfg["threads"], 1):
548 cls_list = [URIRef(BASE + x) for x in th.get("classes") or []]
549 views = [(v.get("title", f"view {j+1}"), v.get("note", ""), list(v.get("predicates") or [])) for j, v in enumerate(th.get("views") or [])] or None
550 # configured threads are drawn whole unless they declare views or opt into `auto_partition: true`
551 thread(sec_thr, i, th.get("title", f"Thread {i}"), cls_list, th.get("note", ""), views, auto=bool(th.get("auto_partition", False)))
552 else:
553 i = 0
554 for r in roots:
555 fam = [r] + descendants(r)
556 if any(instances_of.get(c) for c in fam): i += 1; thread(sec_thr, i, f"{lab(r)} family", fam)
557
558# ----------------------------------------------------------------------------- timeline
559DATE_TYPES = {XSD.date, XSD.dateTime, XSD.gYear, XSD.gYearMonth}
560dated = sorted((str(o_), s_, ln(p_)) for s_, p_, o_ in I.triples((None, None, None)) if isinstance(o_, Literal) and o_.datatype in DATE_TYPES and isinstance(s_, URIRef))
561if dated:
562 section("Timeline (all dated individuals)")
563 L = ["timeline", f" title {TITLE} — dated fixture individuals"]
564 by_year = defaultdict(list)
565 for d, s_, p_ in dated: by_year[d[:4]].append((d, s_, p_))
566 for y in sorted(by_year):
567 L.append(f" section {y}")
568 for d, s_, p_ in by_year[y]:
569 # Mermaid timeline uses ':' as syntax, so ISO time/offset colons must
570 # be normalised in the diagram label. The adjacent table retains the
571 # exact RDF lexical value.
572 timeline_label = d.replace(":", "-")
573 L.append(f" {timeline_label} : {esc(lab(s_)).replace(':', ' -')} ({p_})")
574 mm(L)
575 w("| Date | Subject | Property | Type |"); w("|---|---|---|---|")
576 for d, s_, p_ in dated: w(f"| {d} | `{ln(s_)}` — {lab(s_)} | {p_} | {', '.join(ln(t) for t in I.objects(s_, RDF.type))} |")
577 w()
578
579# ----------------------------------------------------------------------------- CQs
580if cqs:
581 section("Competency questions — full queries, graph paths and results")
582 for cq in cqs:
583 cid = cq.get("id"); m = next((x for x in qman if x.get("cq_id") == cid), None); r = cqres.get(cid, {})
584 w(f"### {cid} ({cq.get('priority','—')}) — {cq.get('question','')}"); w()
585 w(f"- **Purpose:** {cq.get('purpose','—')} · **Entailment:** {m.get('entailment','—') if m else '—'} · **Expected:** `{json.dumps(m.get('expected')) if m else '—'}` · **Result:** **{r.get('status','not run')}**" + (f" — {r.get('message','')}" if r.get('message') else "") + (f" · rows returned: {r.get('rows')}" if r.get('rows') is not None else ""))
586 touched_c = sorted((c for c, s in cq_touch.items() if cid in s and c in classes), key=ln)
587 touched_p = sorted((p_ for p_, s in cq_touch.items() if cid in s and p_ in oprops + dprops), key=ln)
588 w(f"- **Classes traversed:** {', '.join('`'+ln(c)+'`' for c in touched_c) or '—'} · **Properties:** {', '.join('`'+ln(p_)+'`' for p_ in touched_p) or '—'}"); w()
589 if cid in cq_text: w("```sparql"); OUT.extend(cq_text[cid].rstrip().splitlines()); w("```"); w()
590 if r.get("actual"):
591 cols = list(r["actual"][0].keys())
592 w("Actual result rows (from `validation/cq-results.json`):"); w(); w("| " + " | ".join(cols) + " |"); w("|" + "---|" * len(cols))
593 for row in r["actual"]: w("| " + " | ".join(str(row.get(k, "")).replace(BASE, PFX + ":") for k in cols) + " |")
594 w()
595 L = ["flowchart LR", N("Q", cid, "prov")]
596 for c in touched_c: L.append(class_node(nid(c), c)); L.append(E("Q", nid(c), "anchors on", True))
597 for p_ in touched_p:
598 d_ = ln(dom[p_][0]) if dom[p_] else "subject ✱"; r_ = ln(rng[p_][0]) if rng[p_] else "value ✱"
599 L.append(N(f"{nid(p_)}_d", esc(d_), "owl")); L.append(N(f"{nid(p_)}_r", esc(r_), "skos" if r_ == "Concept" else "owl"))
600 L.append(E(f"{nid(p_)}_d", f"{nid(p_)}_r", ln(p_))); L.append(E("Q", f"{nid(p_)}_d", "traverses", True))
601 mm(L)
602
603# ----------------------------------------------------------------------------- traceability
604section("Traceability — source → evidence → term → RDF → fixture → CQ")
605w("One chain per source-supported class, showing every evidence record and source behind it and every competency question that consumes it. Evidence, source and CQ nodes are provenance (neutral, dashed edges), not ontology nodes.")
606w()
607L = ["flowchart LR"]; seen = set()
608for c in classes:
609 t = term_by_iri.get(str(c), {})
610 if not t.get("evidence"): continue
611 cn = nid(c); L.append(N(cn, f'{esc(ln(c))}<br/>{t.get("id","")}', "art" if is_art(c) else "owl"))
612 for e in t["evidence"]:
613 en = f"E_{re.sub(r'[^A-Za-z0-9_]', '_', e)}"
614 if en not in seen: seen.add(en); L.append(N(en, e, "prov"))
615 L.append(E(en, cn, "supports", True))
616 sid = (ev_by_id.get(e) or {}).get("source_id")
617 if sid:
618 sn = f"S_{re.sub(r'[^A-Za-z0-9_]', '_', sid)}"
619 if sn not in seen: seen.add(sn); L.append(N(sn, sid, "prov"))
620 if (sn, en) not in seen: seen.add((sn, en)); L.append(E(sn, en, "cited by", True))
621 for cid in sorted(cq_touch.get(c, [])):
622 qn = f"Q_{re.sub(r'[^A-Za-z0-9_]', '_', cid)}"
623 if qn not in seen: seen.add(qn); L.append(N(qn, cid, "prov"))
624 L.append(E(cn, qn, "answers", True))
625if len(L) > 1: mm(L)
626else: w("No class carries evidence references in `model.yaml`."); w()
627
628# ----------------------------------------------------------------------------- registers
629def cell(x): return str(x if x is not None else "").replace("|", "/").replace("\n", " ").strip()
630section("Source register (all sources, full metadata)")
631w("| ID | Title | Type | Authority | Status | Publisher | Date | Version | Location | Notes |"); w("|---|---|---|---|---|---|---|---|---|---|")
632for s in srcs: w("| " + " | ".join(cell(s.get(k)) for k in ("id", "title", "source_type", "authority", "status", "publisher", "date", "version", "location", "notes")) + " |")
633w()
634section("Evidence register (all records, full claims — nothing truncated)")
635w("| ID | Source | Locator | Support | Confidence | Full claim | Supports | Notes |"); w("|---|---|---|---|---|---|---|---|")
636for e in ev: w(f"| {cell(e.get('id'))} | {cell(e.get('source_id'))} | {cell(json.dumps(e.get('locator')))} | **{cell(e.get('support'))}** | {cell(e.get('confidence'))} | {cell(e.get('claim'))} | {', '.join(e.get('supports') or [])} | {cell(e.get('notes'))} |")
637w()
638if ev: w("Support-state counts: " + ", ".join(f"{k} = {n}" for k, n in sorted(Counter(e.get("support") for e in ev).items(), key=lambda kv: str(kv[0])))); w()
639section("Decision register (full rationale)")
640for d in decs:
641 w(f"### {d.get('id')}{d.get('topic','')} → `{d.get('decision','')}` ({d.get('status','')}, {d.get('date','')})"); w()
642 w(str(d.get("rationale") or "").strip()); w()
643 w(f"Evidence: {', '.join(d.get('evidence') or []) or '—'} · Affects: {', '.join(d.get('affects') or []) or '—'}"); w()
644if not decs: w("No decisions recorded."); w()
645
646section("Mappings, patterns and unresolved issues")
647w("### External mappings (kept at asserted strength)"); w()
648w("| ID | Local | Relation | External | Status | Rationale |"); w("|---|---|---|---|---|---|")
649for m in model.get("mappings") or []: w(f"| {m.get('id')} | `{ln(resolve(m.get('local','')))}` | {m.get('relation')} | {m.get('external')} | **{m.get('status')}** | {cell(m.get('rationale'))} |")
650w()
651if model.get("mappings"):
652 L = ["flowchart LR"]
653 for m in model["mappings"]:
654 loc = resolve(m.get("local", "")); ex_ = str(m.get("external", "")).rsplit("#", 1)[-1].rsplit("/", 1)[-1]
655 L.append(class_node(f"M_{nid(loc)}", URIRef(loc)) if URIRef(loc) in classes else N(f"M_{nid(loc)}", esc(ln(loc)), "owl"))
656 L.append(N(f"X_{nid(ex_)}", esc(ex_), "ext")); L.append(E(f"M_{nid(loc)}", f"X_{nid(ex_)}", f'{esc(m.get("relation",""))} ({m.get("status","")})', True))
657 mm(L)
658w("### Modelling patterns"); w()
659for pt in model.get("patterns") or []:
660 decisions = ", ".join(pt.get("decisions") or []) or "—"
661 w(f"- **{pt.get('id')} {pt.get('name','')}** — {pt.get('description','')} Decisions / requirements: {decisions}.")
662if not model.get("patterns"): w("—")
663w(); w("### Issues and resolutions"); w()
664for i in model.get("unresolved_issues") or []:
665 w(f"#### {i.get('id')} ({i.get('status','')}) — {i.get('question','')}")
666 w()
667 w(f"- **Authority analysis:** {i.get('authority_analysis','—')}")
668 w(f"- **Resolution:** {i.get('resolution','—')}")
669 w(f"- **Evidence for / against:** {', '.join(i.get('evidence_for') or []) or '—'} / {', '.join(i.get('evidence_against') or []) or '—'}")
670 w(f"- **Affects:** {', '.join(i.get('affects') or []) or '—'}")
671 w()
672if not model.get("unresolved_issues"): w("— (none recorded)")
673w(); w("### Accepted axioms (model ledger)"); w()
674w("| ID | Type | Subject | Object / statement | Support · evidence | Rationale |"); w("|---|---|---|---|---|---|")
675for a in model.get("axioms") or []: w(f"| {a.get('id')} | {a.get('type')} | `{ln(resolve(a.get('subject','')))}` | {cell(str(a.get('object')).replace(PFX + ':', PFX + ':'))} | {a.get('support')} · {', '.join(a.get('evidence') or [])} | {cell(a.get('rationale')) or '—'} |")
676w()
677
678# ----------------------------------------------------------------------------- formal subclass appendix
679section("Formal subclass appendix (pure OWL taxonomy — every rdfs:subClassOf)")
680L = ["classDiagram"]
681for c in classes:
682 for p_ in parent[c]: L.append(f" {ln(p_)} <|-- {ln(c)}")
683for c in classes:
684 if not parent[c] and not children.get(c): L.append(f" class {ln(c)}")
685mm(L)
686
687# ----------------------------------------------------------------------------- validation report
688section("Atlas validation report")
689w("### Inputs parsed")
690for name, g in (("ontology.ttl", O), ("taxonomy.ttl", T), ("shapes.ttl", S), ("instances.ttl", I)): w(f"- {name}: {'PASS — ' + str(len(g)) + ' triples' if (PROJ / name).exists() else 'absent'}")
691w(); w("### Coverage (mechanical, `scripts/validate_atlas.py`)")
692w(f"- OWL classes {len(classes)}/{len(classes)} · object properties {len(oprops)}/{len(oprops)} · datatype properties {len(dprops)}/{len(dprops)} · SKOS schemes {len(schemes)}/{len(schemes)} (concepts {len(concepts)}/{len(concepts)}) · SHACL shapes {len(shapes)}/{len(shapes)} · CQs {len(cqs)}/{len(cqs)} · sources {len(srcs)}/{len(srcs)} · evidence {len(ev)}/{len(ev)} · decisions {len(decs)}/{len(decs)} · individuals {len(individuals)}/{len(individuals)} — all enumerated above.")
693w("- Partitioned instance threads: the generator asserts that the union of a thread's views contains every object-property assertion of the thread (build fails otherwise): PASS")
694w(); w("### Semantic integrity")
695w("- SHACL nodes drawn as rounded rectangles on dashed edges, never as OWL nodes; cardinalities labelled as SHACL and stated as not OWL: PASS")
696w("- SKOS concepts drawn as stadiums, never as subclasses: PASS")
697w("- Solid edges = asserted predicates / subClassOf; dashed = provenance, SHACL, rdf:type-of-fixture, interpretive: PASS")
698w("- Focal class of each profile visually dominant (heavy outline); neighbours neutral: PASS")
699w("- Fixture facts confined to the instance-thread/timeline sections and the labelled `Fixture individuals` groups of the class profiles: PASS")
700w("- External mappings shown at asserted strength; proposed/disputed material and open issues surfaced from the ledgers: PASS")
701w(); w("### Renderer validation")
702w("- Every one of the @@NBLOCKS@@ Mermaid blocks in this file begins with the `---` / `config: layout: elk` / `---` frontmatter header, emitted by `scripts/build_atlas.py` before the diagram type line.")
703if render_report:
704 for tag, rr in (render_report.get("configs") or {}).items():
705 w(f"- **Headless Chrome render, {tag}:** {rr.get('rendered')}/{rr.get('total')} blocks rendered to SVG; failures: {rr.get('failures') or 'none'}.")
706 if render_report.get("identical_layouts") is not None:
707 w(f"- ELK vs explicit dagre comparison: {len(render_report.get('identical_layouts') or [])} block(s) identical; {render_report.get('different_layout_count', '—')} block(s) differ. Differences in graph blocks confirm the ELK layout path was applied.")
708 w("- Report: `scripts/render_check.py` output embedded via `--render-report`.")
709else:
710 w("- **Renderer validation: NOT EXECUTED by this generator.** Run `scripts/render_check.py` (headless Chrome) and rebuild with `--render-report` to record real render results; do not claim render validation otherwise.")
711w("- Structural check: `scripts/validate_atlas.py` — fences balanced, ELK header on every block, lexical coverage of classes, properties, SKOS schemes, SHACL shapes, CQs, source/evidence/decision ids (`validation/atlas-validation.json`).")
712w(); w("### Release caveat")
713w("This atlas documents the project bundle and does not itself change the ontology release state (gates in `validation/`).")
714w()
715
716# ----------------------------------------------------------------------------- write
717contents = "## Contents\n\n" + "\n".join(f"{i}. {t}" for i, t in enumerate(SECTIONS, 1))
718text = ("\n".join(OUT) + "\n").replace("@@CONTENTS@@", contents).replace("@@NBLOCKS@@", str(OUT.count("```mermaid")))
719OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
720OUT_PATH.write_text(text, encoding="utf-8")
721print(f"Wrote {OUT_PATH.relative_to(PROJ) if OUT_PATH.is_relative_to(PROJ) else OUT_PATH}{OUT.count('```mermaid')} mermaid blocks, {len(text.splitlines())} lines")