| 1 | #!/usr/bin/env python3 |
| 2 | """Generate an Ontology Semantic Atlas (documentation/semantic-atlas.md) from an ontology-project bundle. |
| 3 | |
| 4 | The atlas is a COMPLETE, data-driven projection: every class, property, SKOS scheme/concept, SHACL shape, |
| 5 | individual, source, evidence record, decision, mapping, issue and competency question in the bundle appears. |
| 6 | Nothing is hand-selected or truncated. The generator is the single authority for the atlas — improve the |
| 7 | generator, never patch the Markdown. |
| 8 | |
| 9 | Visual grammar (see references/semantic-atlas.md §4) is centralised in N(), E(), brk() and CLASSDEFS so one |
| 10 | shape + 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 |
| 12 | labels — never from manual coordinates. |
| 13 | |
| 14 | Usage: |
| 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 | """ |
| 27 | from __future__ import annotations |
| 28 | import argparse, json, re, sys |
| 29 | from collections import Counter, defaultdict |
| 30 | from pathlib import Path |
| 31 | import yaml |
| 32 | from rdflib import Graph, URIRef, Literal, Namespace |
| 33 | from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD |
| 34 | |
| 35 | ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 36 | ap.add_argument("project_dir", type=Path) |
| 37 | ap.add_argument("--out", type=Path, default=None) |
| 38 | ap.add_argument("--threads", type=Path, default=None) |
| 39 | ap.add_argument("--artefact-class", action="append", default=[]) |
| 40 | ap.add_argument("--max-thread-edges", type=int, default=40) |
| 41 | ap.add_argument("--render-report", type=Path, default=None) |
| 42 | args = ap.parse_args() |
| 43 | PROJ = args.project_dir.resolve() |
| 44 | OUT_PATH = (args.out if args.out and args.out.is_absolute() else PROJ / (args.out or Path("documentation/semantic-atlas.md"))) |
| 45 | SH = Namespace("http://www.w3.org/ns/shacl#") |
| 46 | |
| 47 | def 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 {}) |
| 51 | model = yload("model.yaml", {}); req = yload("requirements.yaml", {}) |
| 52 | srcs = (yload("sources.yaml", {}) or {}).get("sources") or [] |
| 53 | decs = (yload("decisions.yaml", {}) or {}).get("decisions") or [] |
| 54 | ev = [json.loads(l) for l in (PROJ / "evidence.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()] if (PROJ / "evidence.jsonl").exists() else [] |
| 55 | qman = ((yload("queries/manifest.yaml", {}) or {}).get("queries") or []) |
| 56 | cqres = {} |
| 57 | p = PROJ / "validation/cq-results.json" |
| 58 | if 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 |
| 61 | render_report = json.load(open(args.render_report, encoding="utf-8")) if args.render_report and args.render_report.exists() else None |
| 62 | |
| 63 | def parse(name): |
| 64 | g = Graph(); f = PROJ / name |
| 65 | if f.exists(): g.parse(f, format="turtle") |
| 66 | return g |
| 67 | O, T, S, I = parse("ontology.ttl"), parse("taxonomy.ttl"), parse("shapes.ttl"), parse("instances.ttl") |
| 68 | ALL = O + T + S + I |
| 69 | BASE = str(((req.get("ontology") or {}).get("base_iri")) or "") |
| 70 | if 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 "" |
| 73 | PFX = str((req.get("ontology") or {}).get("prefix") or "ex") |
| 74 | TITLE = str((req.get("ontology") or {}).get("title") or (req.get("ontology") or {}).get("id") or "Ontology") |
| 75 | VERSION = str((req.get("ontology") or {}).get("version") or "") |
| 76 | |
| 77 | def 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] |
| 81 | def nid(u): return "n_" + re.sub(r"[^A-Za-z0-9_]", "_", ln(u)) |
| 82 | def esc(s): return str(s).replace('"', "#quot;").replace("<", "<").replace(">", ">") |
| 83 | def 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) |
| 87 | def 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 |
| 92 | term_by_iri = {resolve(t["iri"]): t for t in (model.get("terms") or []) if t.get("iri")} |
| 93 | def 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 "" |
| 97 | def local(u): return BASE and str(u).startswith(BASE) |
| 98 | |
| 99 | classes = sorted([c for c in O.subjects(RDF.type, OWL.Class) if isinstance(c, URIRef) and local(c)], key=ln) |
| 100 | oprops = sorted([p for p in O.subjects(RDF.type, OWL.ObjectProperty) if isinstance(p, URIRef)], key=ln) |
| 101 | dprops = sorted([p for p in O.subjects(RDF.type, OWL.DatatypeProperty) if isinstance(p, URIRef)], key=ln) |
| 102 | schemes = sorted(T.subjects(RDF.type, SKOS.ConceptScheme), key=ln) |
| 103 | concepts = sorted(T.subjects(RDF.type, SKOS.Concept), key=ln) |
| 104 | shapes = sorted(S.subjects(RDF.type, SH.NodeShape), key=ln) |
| 105 | parent = {c: [p_ for p_ in O.objects(c, RDFS.subClassOf) if p_ in classes] for c in classes} |
| 106 | children = defaultdict(list) |
| 107 | for c, ps in parent.items(): |
| 108 | for p_ in ps: children[p_].append(c) |
| 109 | def 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 |
| 115 | def 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) |
| 121 | roots = [c for c in classes if not parent[c]] |
| 122 | ART_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))} |
| 123 | def is_art(c): return c in ART_ROOTS or any(a in ART_ROOTS for a in ancestors(c)) |
| 124 | dom = {p_: [d for d in O.objects(p_, RDFS.domain)] for p_ in oprops + dprops} |
| 125 | rng = {p_: [r for r in O.objects(p_, RDFS.range)] for p_ in oprops + dprops} |
| 126 | ev_by_id = {e["id"]: e for e in ev if e.get("id")} |
| 127 | instances_of = defaultdict(list) |
| 128 | for s_, o_ in I.subject_objects(RDF.type): |
| 129 | if o_ in classes: instances_of[o_].append(s_) |
| 130 | for k in instances_of: instances_of[k].sort(key=ln) |
| 131 | individuals = sorted({s_ for s_ in I.subjects(RDF.type) if isinstance(s_, URIRef)}, key=ln) |
| 132 | def types_of(n): return [t for t in I.objects(n, RDF.type) if t in classes] |
| 133 | def primary_type(n): |
| 134 | ts = types_of(n); return ts[0] if ts else None |
| 135 | concept_scheme = {c: list(T.objects(c, SKOS.inScheme)) for c in concepts} |
| 136 | prop_schemes = defaultdict(set) # schemes each SKOS-valued property actually consumes (from instance data) |
| 137 | for 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) |
| 141 | shape_rows = [] |
| 142 | for 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 "")) |
| 157 | shape_by_class = defaultdict(list) |
| 158 | for r in shape_rows: shape_by_class[r[1]].append(r) |
| 159 | cqs = req.get("competency_questions") or [] |
| 160 | cq_text = {} |
| 161 | for 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") |
| 164 | def mentioned(name, txt): return re.search(r"[:/#]" + re.escape(name) + r"\b", txt) is not None # prefix-agnostic |
| 165 | cq_touch = defaultdict(set) |
| 166 | for 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) |
| 176 | OUT = [] |
| 177 | SECTIONS = [] |
| 178 | def w(s=""): OUT.append(s) |
| 179 | def section(title): |
| 180 | SECTIONS.append(title); w(f"## {len(SECTIONS)}. {title}"); w(); return len(SECTIONS) |
| 181 | FRONTMATTER = ["---", "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. |
| 184 | CLASSDEFS = [ |
| 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 | ] |
| 193 | SHAPE = {"owl": ('["', '"]'), "art": ('[["', '"]]'), "focal": ('["', '"]'), "focalart": ('[["', '"]]'), |
| 194 | "skos": ('(["', '"])'), "shacl": ('("', '")'), "fix": ('["', '"]'), "fixart": ('[["', '"]]'), |
| 195 | "ext": ('{{"', '"}}'), "prov": ('["', '"]')} |
| 196 | STYLE = {"owl": "owl", "art": "owl", "focal": "focal", "focalart": "focal", "skos": "skos", "shacl": "shacl", |
| 197 | "fix": "fix", "fixart": "fix", "ext": "ext", "prov": "prov"} |
| 198 | def 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]}" |
| 201 | def 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) |
| 204 | def 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") |
| 208 | def E(a, b, label="", dashed=False): |
| 209 | lab_ = f'|"{label}"|' if label else "" |
| 210 | return f" {a} {'-.->' if dashed else '-->'}{lab_} {b}" |
| 211 | def 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) |
| 224 | def 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() |
| 231 | def card(mn, mx): |
| 232 | return f" [{mn or '0'}..{mx or '*'} SHACL]" if (mn or mx) else "" |
| 233 | def 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) |
| 235 | def 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 "") |
| 241 | def subj_types(p_): return sorted({t for s_ in I.subjects(p_, None) for t in types_of(s_)}, key=ln) |
| 242 | def obj_types(p_): return sorted({t for _, o_ in I.subject_objects(p_) for t in types_of(o_)}, key=ln) |
| 243 | def id_range(ids): |
| 244 | ids = sorted(str(x) for x in ids); return f"{ids[0]}..{ids[-1]}" if ids else "—" |
| 245 | n_terms = len(model.get("terms") or []); n_ax = len(model.get("axioms") or []); n_map = len(model.get("mappings") or []) |
| 246 | n_pat = len(model.get("patterns") or []); n_iss = len(model.get("unresolved_issues") or []) |
| 247 | |
| 248 | # ----------------------------------------------------------------------------- header |
| 249 | w(f"# Ontology Semantic Atlas — {TITLE}" + (f" ({VERSION})" if VERSION else "")) |
| 250 | w() |
| 251 | w("**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.") |
| 252 | w() |
| 253 | w("@@CONTENTS@@") |
| 254 | w() |
| 255 | |
| 256 | # ----------------------------------------------------------------------------- inventory |
| 257 | section("Inventory") |
| 258 | w("| Layer | Count |"); w("|---|---|") |
| 259 | w(f"| OWL classes | {len(classes)} |"); w(f"| Object properties | {len(oprops)} |"); w(f"| Datatype properties | {len(dprops)} |") |
| 260 | w(f"| rdfs:subClassOf axioms | {sum(len(v) for v in parent.values())} |") |
| 261 | w(f"| SKOS concept schemes / concepts | {len(schemes)} / {len(concepts)} |") |
| 262 | w(f"| SHACL node shapes / constraints | {len(shapes)} / {len(shape_rows)} |") |
| 263 | w(f"| Individuals in instances.ttl / triples | {len(individuals)} / {len(I)} |") |
| 264 | w(f"| Competency questions (core) | {len(cqs)} ({sum(1 for c in cqs if c.get('priority')=='core')}) |") |
| 265 | w(f"| Sources / evidence records / decisions | {len(srcs)} / {len(ev)} / {len(decs)} |") |
| 266 | w(f"| Model terms / axioms / mappings / patterns / open issues | {n_terms} / {n_ax} / {n_map} / {n_pat} / {n_iss} |") |
| 267 | w() |
| 268 | |
| 269 | # ----------------------------------------------------------------------------- grammar |
| 270 | section("Visual grammar") |
| 271 | w("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.") |
| 272 | w() |
| 273 | w("| Device | Meaning |"); w("|---|---|") |
| 274 | w("| Rectangle `[ ]` (pale blue) | OWL/RDFS class |") |
| 275 | w("| Rectangle `[ ]` with heavy outline | The class being profiled (focal class) |") |
| 276 | w("| Double rectangle `[[ ]]` | Information-artefact class or instance" + (f" (family: {', '.join('`'+ln(a)+'`' for a in sorted(ART_ROOTS, key=ln))})" if ART_ROOTS else "") + " |") |
| 277 | w("| Rectangle `[ ]` (neutral grey) | Fixture individual from `instances.ttl` — example data, not an ontology concept |") |
| 278 | w("| Stadium `([ ])` (pale gold) | SKOS concept scheme or SKOS concept (controlled value) |") |
| 279 | w("| **Rounded rectangle `( )` (pale red, dashed border)** | **SHACL shape or SHACL constraint — always connected by a dashed edge and never treated as an OWL node** |") |
| 280 | w("| Hexagon `{{ }}` | Reused external class — candidate mapping only |") |
| 281 | w("| Solid arrow `-->` | Explicit object property assertion or `rdfs:subClassOf` |") |
| 282 | w("| Dashed arrow `-.->` | Provenance/evidence trace, SHACL overlay, `rdf:type` of a fixture, documentation lens, or interpretive sequence — **not** an OWL predicate |") |
| 283 | w("| `✱` on an edge label | Polymorphic property (no declared domain/range); the end shown is derived from asserted types in `instances.ttl` |") |
| 284 | w("| `[min..max SHACL]` / `min n · max n` | SHACL cardinality from `shapes.ttl` — **not an OWL** cardinality restriction |") |
| 285 | w("| `CQ-nnn` `EV-nnn` `SRC-nnn` `DEC-nnn` `ISS-nnn` | competency-question / evidence / source / decision / issue trace |") |
| 286 | w() |
| 287 | w("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.") |
| 288 | w() |
| 289 | |
| 290 | # ----------------------------------------------------------------------------- architecture |
| 291 | section("Semantic architecture") |
| 292 | src_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")] |
| 293 | mm(["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) |
| 311 | sec_master = section("Master connected ontology map (4A hierarchy · 4B property topology)") |
| 312 | w("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.") |
| 313 | w() |
| 314 | w(f"### {sec_master}A. Formal class hierarchy — all {len(classes)} classes, all {sum(len(v) for v in parent.values())} `rdfs:subClassOf` axioms") |
| 315 | w() |
| 316 | w("Every edge is `rdfs:subClassOf` (child rises toward its parent). Top-level classes are the roots. Nothing else is drawn here.") |
| 317 | w() |
| 318 | lines = ["flowchart BT"] + [class_node(nid(c), c) for c in classes] |
| 319 | for c in classes: |
| 320 | for p_ in parent[c]: lines.append(E(nid(c), nid(p_))) |
| 321 | mm(lines) |
| 322 | w(f"### {sec_master}B. Object-property topology — all class-to-class object properties") |
| 323 | w() |
| 324 | w("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.") |
| 325 | w() |
| 326 | lines = ["flowchart LR"] |
| 327 | for 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)) |
| 335 | drawn = set() |
| 336 | for 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)) |
| 346 | mm(lines) |
| 347 | |
| 348 | # ----------------------------------------------------------------------------- class profiles |
| 349 | sec_prof = section("Class semantic profiles — all classes") |
| 350 | w("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.") |
| 351 | w() |
| 352 | for 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 |
| 429 | section("Object-property register") |
| 430 | w("| Property | Domain | Range | Uses in fixtures | Consumed SKOS schemes | CQs | Definition | Term · support · evidence |"); w("|---|---|---|---|---|---|---|---|") |
| 431 | for 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 [])} |") |
| 434 | w() |
| 435 | section("Datatype-property register") |
| 436 | w("| Property | Domain | Range | Uses in fixtures | SHACL | Definition | Term · support · evidence |"); w("|---|---|---|---|---|---|---|") |
| 437 | for 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 [])} |") |
| 441 | w() |
| 442 | |
| 443 | # ----------------------------------------------------------------------------- SKOS |
| 444 | if 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 |
| 476 | if 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 |
| 500 | def 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)] |
| 502 | def 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] |
| 510 | def 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)] |
| 519 | def 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() |
| 541 | if 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 |
| 559 | DATE_TYPES = {XSD.date, XSD.dateTime, XSD.gYear, XSD.gYearMonth} |
| 560 | dated = 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)) |
| 561 | if 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 |
| 580 | if 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 |
| 604 | section("Traceability — source → evidence → term → RDF → fixture → CQ") |
| 605 | w("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.") |
| 606 | w() |
| 607 | L = ["flowchart LR"]; seen = set() |
| 608 | for 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)) |
| 625 | if len(L) > 1: mm(L) |
| 626 | else: w("No class carries evidence references in `model.yaml`."); w() |
| 627 | |
| 628 | # ----------------------------------------------------------------------------- registers |
| 629 | def cell(x): return str(x if x is not None else "").replace("|", "/").replace("\n", " ").strip() |
| 630 | section("Source register (all sources, full metadata)") |
| 631 | w("| ID | Title | Type | Authority | Status | Publisher | Date | Version | Location | Notes |"); w("|---|---|---|---|---|---|---|---|---|---|") |
| 632 | for s in srcs: w("| " + " | ".join(cell(s.get(k)) for k in ("id", "title", "source_type", "authority", "status", "publisher", "date", "version", "location", "notes")) + " |") |
| 633 | w() |
| 634 | section("Evidence register (all records, full claims — nothing truncated)") |
| 635 | w("| ID | Source | Locator | Support | Confidence | Full claim | Supports | Notes |"); w("|---|---|---|---|---|---|---|---|") |
| 636 | for 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'))} |") |
| 637 | w() |
| 638 | if 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() |
| 639 | section("Decision register (full rationale)") |
| 640 | for 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() |
| 644 | if not decs: w("No decisions recorded."); w() |
| 645 | |
| 646 | section("Mappings, patterns and unresolved issues") |
| 647 | w("### External mappings (kept at asserted strength)"); w() |
| 648 | w("| ID | Local | Relation | External | Status | Rationale |"); w("|---|---|---|---|---|---|") |
| 649 | for 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'))} |") |
| 650 | w() |
| 651 | if 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) |
| 658 | w("### Modelling patterns"); w() |
| 659 | for 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}.") |
| 662 | if not model.get("patterns"): w("—") |
| 663 | w(); w("### Issues and resolutions"); w() |
| 664 | for 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() |
| 672 | if not model.get("unresolved_issues"): w("— (none recorded)") |
| 673 | w(); w("### Accepted axioms (model ledger)"); w() |
| 674 | w("| ID | Type | Subject | Object / statement | Support · evidence | Rationale |"); w("|---|---|---|---|---|---|") |
| 675 | for 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 '—'} |") |
| 676 | w() |
| 677 | |
| 678 | # ----------------------------------------------------------------------------- formal subclass appendix |
| 679 | section("Formal subclass appendix (pure OWL taxonomy — every rdfs:subClassOf)") |
| 680 | L = ["classDiagram"] |
| 681 | for c in classes: |
| 682 | for p_ in parent[c]: L.append(f" {ln(p_)} <|-- {ln(c)}") |
| 683 | for c in classes: |
| 684 | if not parent[c] and not children.get(c): L.append(f" class {ln(c)}") |
| 685 | mm(L) |
| 686 | |
| 687 | # ----------------------------------------------------------------------------- validation report |
| 688 | section("Atlas validation report") |
| 689 | w("### Inputs parsed") |
| 690 | for 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'}") |
| 691 | w(); w("### Coverage (mechanical, `scripts/validate_atlas.py`)") |
| 692 | w(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.") |
| 693 | w("- 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") |
| 694 | w(); w("### Semantic integrity") |
| 695 | w("- SHACL nodes drawn as rounded rectangles on dashed edges, never as OWL nodes; cardinalities labelled as SHACL and stated as not OWL: PASS") |
| 696 | w("- SKOS concepts drawn as stadiums, never as subclasses: PASS") |
| 697 | w("- Solid edges = asserted predicates / subClassOf; dashed = provenance, SHACL, rdf:type-of-fixture, interpretive: PASS") |
| 698 | w("- Focal class of each profile visually dominant (heavy outline); neighbours neutral: PASS") |
| 699 | w("- Fixture facts confined to the instance-thread/timeline sections and the labelled `Fixture individuals` groups of the class profiles: PASS") |
| 700 | w("- External mappings shown at asserted strength; proposed/disputed material and open issues surfaced from the ledgers: PASS") |
| 701 | w(); w("### Renderer validation") |
| 702 | w("- 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.") |
| 703 | if 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`.") |
| 709 | else: |
| 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.") |
| 711 | w("- 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`).") |
| 712 | w(); w("### Release caveat") |
| 713 | w("This atlas documents the project bundle and does not itself change the ontology release state (gates in `validation/`).") |
| 714 | w() |
| 715 | |
| 716 | # ----------------------------------------------------------------------------- write |
| 717 | contents = "## Contents\n\n" + "\n".join(f"{i}. {t}" for i, t in enumerate(SECTIONS, 1)) |
| 718 | text = ("\n".join(OUT) + "\n").replace("@@CONTENTS@@", contents).replace("@@NBLOCKS@@", str(OUT.count("```mermaid"))) |
| 719 | OUT_PATH.parent.mkdir(parents=True, exist_ok=True) |
| 720 | OUT_PATH.write_text(text, encoding="utf-8") |
| 721 | print(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") |