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