#!/usr/bin/env python3
"""Generate an Ontology Semantic Atlas (documentation/semantic-atlas.md) from an ontology-project bundle.

The atlas is a COMPLETE, data-driven projection: every class, property, SKOS scheme/concept, SHACL shape,
individual, source, evidence record, decision, mapping, issue and competency question in the bundle appears.
Nothing is hand-selected or truncated. The generator is the single authority for the atlas — improve the
generator, never patch the Markdown.

Visual grammar (see references/semantic-atlas.md §4) is centralised in N(), E(), brk() and CLASSDEFS so one
shape + one colour per semantic category is applied identically in every diagram. Layout is always ELK
(frontmatter on every block); readability comes from direction, grouping, declaration order and short
labels — never from manual coordinates.

Usage:
  build_atlas.py <project-dir> [--out documentation/semantic-atlas.md] [--threads atlas-threads.yaml]
                 [--artefact-class LocalName ...] [--max-thread-edges 40] [--render-report render-report.json]

  --threads          optional YAML describing instance threads and their partitioned views (see reference §12);
                     without it, one thread per top-level class family is generated and threads with more
                     than --max-thread-edges assertions are partitioned automatically by predicate. A
                     configured thread is drawn whole unless it lists `views` or sets `auto_partition: true`.
  --artefact-class   root class(es) whose members are information artefacts (double-rectangle shape); default:
                     auto-detect root classes named *Artefact|*Artifact|*Document|*InformationItem.
  --render-report    JSON written by scripts/render_check.py; when given, the atlas validation section reports
                     the real renderer results instead of "not executed".
"""
from __future__ import annotations
import argparse, json, re, sys
from collections import Counter, defaultdict
from pathlib import Path
import yaml
from rdflib import Graph, URIRef, Literal, Namespace
from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD

ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("project_dir", type=Path)
ap.add_argument("--out", type=Path, default=None)
ap.add_argument("--threads", type=Path, default=None)
ap.add_argument("--artefact-class", action="append", default=[])
ap.add_argument("--max-thread-edges", type=int, default=40)
ap.add_argument("--render-report", type=Path, default=None)
args = ap.parse_args()
PROJ = args.project_dir.resolve()
OUT_PATH = (args.out if args.out and args.out.is_absolute() else PROJ / (args.out or Path("documentation/semantic-atlas.md")))
SH = Namespace("http://www.w3.org/ns/shacl#")

def yload(p, default=None):
    p = PROJ / p
    if not p.exists(): return default if default is not None else {}
    return yaml.safe_load(p.read_text(encoding="utf-8")) or (default if default is not None else {})
model = yload("model.yaml", {}); req = yload("requirements.yaml", {})
srcs = (yload("sources.yaml", {}) or {}).get("sources") or []
decs = (yload("decisions.yaml", {}) or {}).get("decisions") or []
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 []
qman = ((yload("queries/manifest.yaml", {}) or {}).get("queries") or [])
cqres = {}
p = PROJ / "validation/cq-results.json"
if p.exists():
    d = json.load(open(p, encoding="utf-8"))
    for r in d.get("results", d.get("tests", [])): cqres[r.get("cq_id")] = r
render_report = json.load(open(args.render_report, encoding="utf-8")) if args.render_report and args.render_report.exists() else None

def parse(name):
    g = Graph(); f = PROJ / name
    if f.exists(): g.parse(f, format="turtle")
    return g
O, T, S, I = parse("ontology.ttl"), parse("taxonomy.ttl"), parse("shapes.ttl"), parse("instances.ttl")
ALL = O + T + S + I
BASE = str(((req.get("ontology") or {}).get("base_iri")) or "")
if not BASE:  # infer: most common namespace among declared classes
    ns = Counter(re.sub(r"[^/#]+$", "", str(c)) for c in O.subjects(RDF.type, OWL.Class) if isinstance(c, URIRef))
    BASE = ns.most_common(1)[0][0] if ns else ""
PFX = str((req.get("ontology") or {}).get("prefix") or "ex")
TITLE = str((req.get("ontology") or {}).get("title") or (req.get("ontology") or {}).get("id") or "Ontology")
VERSION = str((req.get("ontology") or {}).get("version") or "")

def ln(u):
    s = str(u)
    if BASE and s.startswith(BASE): return s[len(BASE):]
    return s.rsplit("#", 1)[-1].rsplit("/", 1)[-1]
def nid(u): return "n_" + re.sub(r"[^A-Za-z0-9_]", "_", ln(u))
def esc(s): return str(s).replace('"', "#quot;").replace("<", "&lt;").replace(">", "&gt;")
def lab(u, g=ALL):
    for L in g.objects(u, RDFS.label): return str(L)
    for L in g.objects(u, SKOS.prefLabel): return str(L)
    return ln(u)
def resolve(iri):
    """model.yaml IRIs may be full or prefixed (`ex:Name`); resolve prefixed forms against the base IRI."""
    s = str(iri)
    if re.match(r"^https?://", s): return s
    return BASE + s.split(":", 1)[1] if ":" in s else BASE + s
term_by_iri = {resolve(t["iri"]): t for t in (model.get("terms") or []) if t.get("iri")}
def defn(u):
    for c in O.objects(u, RDFS.comment): return str(c)
    for c in T.objects(u, SKOS.definition): return str(c)
    t = term_by_iri.get(str(u)); return str(t.get("definition") or "") if t else ""
def local(u): return BASE and str(u).startswith(BASE)

classes = sorted([c for c in O.subjects(RDF.type, OWL.Class) if isinstance(c, URIRef) and local(c)], key=ln)
oprops = sorted([p for p in O.subjects(RDF.type, OWL.ObjectProperty) if isinstance(p, URIRef)], key=ln)
dprops = sorted([p for p in O.subjects(RDF.type, OWL.DatatypeProperty) if isinstance(p, URIRef)], key=ln)
schemes = sorted(T.subjects(RDF.type, SKOS.ConceptScheme), key=ln)
concepts = sorted(T.subjects(RDF.type, SKOS.Concept), key=ln)
shapes = sorted(S.subjects(RDF.type, SH.NodeShape), key=ln)
parent = {c: [p_ for p_ in O.objects(c, RDFS.subClassOf) if p_ in classes] for c in classes}
children = defaultdict(list)
for c, ps in parent.items():
    for p_ in ps: children[p_].append(c)
def ancestors(c):
    out, stack = [], list(parent.get(c, []))
    while stack:
        a = stack.pop()
        if a not in out: out.append(a); stack += parent.get(a, [])
    return out
def descendants(c):
    out, stack = [], list(children.get(c, []))
    while stack:
        a = stack.pop()
        if a not in out: out.append(a); stack += children.get(a, [])
    return sorted(out, key=ln)
roots = [c for c in classes if not parent[c]]
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))}
def is_art(c): return c in ART_ROOTS or any(a in ART_ROOTS for a in ancestors(c))
dom = {p_: [d for d in O.objects(p_, RDFS.domain)] for p_ in oprops + dprops}
rng = {p_: [r for r in O.objects(p_, RDFS.range)] for p_ in oprops + dprops}
ev_by_id = {e["id"]: e for e in ev if e.get("id")}
instances_of = defaultdict(list)
for s_, o_ in I.subject_objects(RDF.type):
    if o_ in classes: instances_of[o_].append(s_)
for k in instances_of: instances_of[k].sort(key=ln)
individuals = sorted({s_ for s_ in I.subjects(RDF.type) if isinstance(s_, URIRef)}, key=ln)
def types_of(n): return [t for t in I.objects(n, RDF.type) if t in classes]
def primary_type(n):
    ts = types_of(n); return ts[0] if ts else None
concept_scheme = {c: list(T.objects(c, SKOS.inScheme)) for c in concepts}
prop_schemes = defaultdict(set)   # schemes each SKOS-valued property actually consumes (from instance data)
for p_ in oprops:
    for _, o_ in I.subject_objects(p_):
        for sc in concept_scheme.get(o_, []): prop_schemes[p_].add(sc)
# SHACL rows: (shape, targetClass, path, min, max, kind, allowed values, message)
shape_rows = []
for sh in shapes:
    tc = list(S.objects(sh, SH.targetClass))
    for ps in S.objects(sh, SH.property):
        path = list(S.objects(ps, SH.path)); mn = list(S.objects(ps, SH.minCount)); mx = list(S.objects(ps, SH.maxCount))
        dt = list(S.objects(ps, SH.datatype)); cl = list(S.objects(ps, SH["class"])); msg = list(S.objects(ps, SH.message))
        inl = list(S.objects(ps, SH["in"])); vals = []
        if inl:
            node = inl[0]
            while node != RDF.nil:
                vals.append(ln(list(S.objects(node, RDF.first))[0])); node = list(S.objects(node, RDF.rest))[0]
        kind = f"xsd:{ln(dt[0])}" if dt else (f"class {ln(cl[0])}" if cl else "")
        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 ""))
    for sp in S.objects(sh, SH.sparql):
        msg = list(S.objects(sp, SH.message))
        shape_rows.append((sh, tc[0] if tc else None, None, "", "", "SPARQL constraint", "", str(msg[0]) if msg else ""))
shape_by_class = defaultdict(list)
for r in shape_rows: shape_by_class[r[1]].append(r)
cqs = req.get("competency_questions") or []
cq_text = {}
for qq in qman:
    f = PROJ / "queries" / str(qq.get("file", ""))
    if qq.get("cq_id") and f.exists(): cq_text[qq["cq_id"]] = f.read_text(encoding="utf-8")
def mentioned(name, txt): return re.search(r"[:/#]" + re.escape(name) + r"\b", txt) is not None   # prefix-agnostic
cq_touch = defaultdict(set)
for cid, txt in cq_text.items():
    for c in classes:
        if mentioned(ln(c), txt): cq_touch[c].add(cid)
    for p_ in oprops + dprops:
        if mentioned(ln(p_), txt): cq_touch[p_].add(cid)
    for s_ in individuals:
        if mentioned(ln(s_), txt):
            for t in types_of(s_): cq_touch[t].add(cid)

# ----------------------------------------------------------------------------- Mermaid helpers (single visual grammar)
OUT = []
SECTIONS = []
def w(s=""): OUT.append(s)
def section(title):
    SECTIONS.append(title); w(f"## {len(SECTIONS)}. {title}"); w(); return len(SECTIONS)
FRONTMATTER = ["---", "config:", "  layout: elk", "---"]
# One colour per semantic category — a redundant cue only; every distinction is also carried by node shape
# and edge style, so the diagrams remain readable in monochrome.
CLASSDEFS = [
    "  classDef owl fill:#e3eefc,stroke:#2f5d9e,color:#111",
    "  classDef focal fill:#e3eefc,stroke:#1a3c6e,stroke-width:3px,color:#111",
    "  classDef skos fill:#fff3c4,stroke:#9a7b00,color:#111",
    "  classDef shacl fill:#fde2e1,stroke:#a33333,stroke-dasharray:4 2,color:#111",
    "  classDef fix fill:#f0f0f0,stroke:#777,color:#222",
    "  classDef ext fill:#ffffff,stroke:#555,stroke-dasharray:2 2,color:#111",
    "  classDef prov fill:#f7f7f7,stroke:#999,color:#333",
]
SHAPE = {"owl": ('["', '"]'), "art": ('[["', '"]]'), "focal": ('["', '"]'), "focalart": ('[["', '"]]'),
         "skos": ('(["', '"])'), "shacl": ('("', '")'), "fix": ('["', '"]'), "fixart": ('[["', '"]]'),
         "ext": ('{{"', '"}}'), "prov": ('["', '"]')}
STYLE = {"owl": "owl", "art": "owl", "focal": "focal", "focalart": "focal", "skos": "skos", "shacl": "shacl",
         "fix": "fix", "fixart": "fix", "ext": "ext", "prov": "prov"}
def N(i, text, kind):
    """Node declaration: id, already-escaped label, semantic category."""
    o, c = SHAPE[kind]; return f"  {i}{o}{text}{c}:::{STYLE[kind]}"
def class_node(i, cls, focal=False):
    k = ("focalart" if focal else "art") if is_art(cls) else ("focal" if focal else "owl")
    return N(i, esc(ln(cls)), k)
def ind_node(i, ind, with_type=True):
    t = primary_type(ind)
    txt = brk(lab(ind)) + (f"<br/><i>{esc(ln(t))}</i>" if with_type and t is not None else "")
    return N(i, txt, "fixart" if (t is not None and is_art(t)) else "fix")
def E(a, b, label="", dashed=False):
    lab_ = f'|"{label}"|' if label else ""
    return f"  {a} {'-.->' if dashed else '-->'}{lab_} {b}"
def brk(s, width=20):
    """Short display label: deterministic <br/> breaks at phrase boundaries (before a parenthesis, after an
    em-dash / ';' / ':') and otherwise at the last space before `width` characters (kept under Mermaid's own
    200 px wrap so the generator's breaks are the binding ones). The verbose literal lives in the adjacent table."""
    s = str(s)
    if len(s) <= width: return esc(s)
    lines, cur = [], ""
    for wd in s.split(" "):
        boundary = wd.startswith("(") or (cur.endswith(("—", ";", ":")) and cur)
        if cur and (len(cur) + 1 + len(wd) > width or boundary): lines.append(cur); cur = wd
        else: cur = (cur + " " + wd) if cur else wd
    if cur: lines.append(cur)
    return "<br/>".join(esc(x) for x in lines)
def mm(lines):
    """Emit a fenced Mermaid block. EVERY block starts with the ELK-layout config frontmatter (the `---`
    header must be the first lines inside the fence, before the diagram type line). Flowcharts get the
    shared classDefs so the same semantic category always has the same presentation."""
    w("```mermaid"); OUT.extend(FRONTMATTER); OUT.extend(lines)
    if lines and lines[0].startswith("flowchart"): OUT.extend(CLASSDEFS)
    w("```"); w()
def card(mn, mx):
    return f" [{mn or '0'}..{mx or '*'} SHACL]" if (mn or mx) else ""
def card_short(mn, mx):
    return " · ".join(x for x in ((f"min {mn}" if mn else ""), (f"max {mx}" if mx else "")) if x)
def constraint_label(r):
    """Concise SHACL constraint label (path / min / max / datatype / class / allowed values)."""
    if not r[2]: return "SPARQL constraint"
    bits = [x for x in (card_short(r[3], r[4]), r[5]) if x]
    if r[6]: bits.append("in {" + r[6] + "}")
    return esc(ln(r[2])) + ("<br/>" + esc(" · ".join(bits)) if bits else "")
def subj_types(p_): return sorted({t for s_ in I.subjects(p_, None) for t in types_of(s_)}, key=ln)
def obj_types(p_): return sorted({t for _, o_ in I.subject_objects(p_) for t in types_of(o_)}, key=ln)
def id_range(ids):
    ids = sorted(str(x) for x in ids); return f"{ids[0]}..{ids[-1]}" if ids else "—"
n_terms = len(model.get("terms") or []); n_ax = len(model.get("axioms") or []); n_map = len(model.get("mappings") or [])
n_pat = len(model.get("patterns") or []); n_iss = len(model.get("unresolved_issues") or [])

# ----------------------------------------------------------------------------- header
w(f"# Ontology Semantic Atlas — {TITLE}" + (f" ({VERSION})" if VERSION else ""))
w()
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.")
w()
w("@@CONTENTS@@")
w()

# ----------------------------------------------------------------------------- inventory
section("Inventory")
w("| Layer | Count |"); w("|---|---|")
w(f"| OWL classes | {len(classes)} |"); w(f"| Object properties | {len(oprops)} |"); w(f"| Datatype properties | {len(dprops)} |")
w(f"| rdfs:subClassOf axioms | {sum(len(v) for v in parent.values())} |")
w(f"| SKOS concept schemes / concepts | {len(schemes)} / {len(concepts)} |")
w(f"| SHACL node shapes / constraints | {len(shapes)} / {len(shape_rows)} |")
w(f"| Individuals in instances.ttl / triples | {len(individuals)} / {len(I)} |")
w(f"| Competency questions (core) | {len(cqs)} ({sum(1 for c in cqs if c.get('priority')=='core')}) |")
w(f"| Sources / evidence records / decisions | {len(srcs)} / {len(ev)} / {len(decs)} |")
w(f"| Model terms / axioms / mappings / patterns / open issues | {n_terms} / {n_ax} / {n_map} / {n_pat} / {n_iss} |")
w()

# ----------------------------------------------------------------------------- grammar
section("Visual grammar")
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.")
w()
w("| Device | Meaning |"); w("|---|---|")
w("| Rectangle `[ ]` (pale blue) | OWL/RDFS class |")
w("| Rectangle `[ ]` with heavy outline | The class being profiled (focal class) |")
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 "") + " |")
w("| Rectangle `[ ]` (neutral grey) | Fixture individual from `instances.ttl` — example data, not an ontology concept |")
w("| Stadium `([ ])` (pale gold) | SKOS concept scheme or SKOS concept (controlled value) |")
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** |")
w("| Hexagon `{{ }}` | Reused external class — candidate mapping only |")
w("| Solid arrow `-->` | Explicit object property assertion or `rdfs:subClassOf` |")
w("| Dashed arrow `-.->` | Provenance/evidence trace, SHACL overlay, `rdf:type` of a fixture, documentation lens, or interpretive sequence — **not** an OWL predicate |")
w("| `✱` on an edge label | Polymorphic property (no declared domain/range); the end shown is derived from asserted types in `instances.ttl` |")
w("| `[min..max SHACL]` / `min n · max n` | SHACL cardinality from `shapes.ttl` — **not an OWL** cardinality restriction |")
w("| `CQ-nnn` `EV-nnn` `SRC-nnn` `DEC-nnn` `ISS-nnn` | competency-question / evidence / source / decision / issue trace |")
w()
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.")
w()

# ----------------------------------------------------------------------------- architecture
section("Semantic architecture")
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")]
mm(["flowchart TB",
    N("REQ", f"requirements.yaml<br/>scope · {len(cqs)} CQs · release policy", "prov"),
    N("SRC", f"sources.yaml<br/>{len(srcs)} sources {id_range(src_ids)}", "prov"),
    N("EV", f"evidence.jsonl<br/>{len(ev)} records {id_range(ev_ids)}", "prov"),
    N("DEC", f"decisions.yaml<br/>{len(decs)} decisions {id_range(dec_ids)}", "prov"),
    N("MOD", f"model.yaml<br/>{n_terms} terms · {n_ax} axioms · {n_map} mappings · {n_iss} issues", "prov"),
    N("OWL", f"ontology.ttl<br/>{len(classes)} classes · {len(oprops)} object props · {len(dprops)} datatype props", "owl"),
    N("TAX", f"taxonomy.ttl<br/>{len(schemes)} SKOS schemes · {len(concepts)} concepts", "skos"),
    N("SHP", f"shapes.ttl<br/>{len(shapes)} SHACL node shapes", "shacl"),
    N("INST", f"instances.ttl<br/>{len(individuals)} individuals · {len(I)} triples", "fix"),
    N("CQ", f"queries/<br/>{len(qman)} SPARQL tests", "prov"),
    N("VAL", "validation/<br/>project · CQ · SHACL · atlas reports", "prov"),
    E("SRC", "EV", "cited by", True), E("EV", "MOD", "supports", True), E("DEC", "MOD", "constrains", True), E("REQ", "MOD", "governs", True),
    E("MOD", "OWL", "formalised as"), E("MOD", "TAX", "formalised as"), E("MOD", "SHP", "informs", True),
    E("OWL", "INST"), E("TAX", "INST"), E("SHP", "INST", "validates", True), E("INST", "CQ", "answered by", True),
    E("CQ", "VAL", "results", True), E("SHP", "VAL", "results", True), E("OWL", "VAL", "results", True)])

# ----------------------------------------------------------------------------- master map (two projections)
sec_master = section("Master connected ontology map (4A hierarchy · 4B property topology)")
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.")
w()
w(f"### {sec_master}A. Formal class hierarchy — all {len(classes)} classes, all {sum(len(v) for v in parent.values())} `rdfs:subClassOf` axioms")
w()
w("Every edge is `rdfs:subClassOf` (child rises toward its parent). Top-level classes are the roots. Nothing else is drawn here.")
w()
lines = ["flowchart BT"] + [class_node(nid(c), c) for c in classes]
for c in classes:
    for p_ in parent[c]: lines.append(E(nid(c), nid(p_)))
mm(lines)
w(f"### {sec_master}B. Object-property topology — all class-to-class object properties")
w()
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.")
w()
lines = ["flowchart LR"]
for r in roots:
    kids = descendants(r)
    if kids:
        lines.append(f'  subgraph G_{ln(r)}["{esc(lab(r))} and subclasses"]')
        lines.append("  " + class_node(nid(r), r))
        for k in kids: lines.append("  " + class_node(nid(k), k))
        lines.append("  end")
    else: lines.append(class_node(nid(r), r))
drawn = set()
for p_ in oprops:
    d_, r_ = dom[p_], rng[p_]
    if r_ and r_[0] == SKOS.Concept: continue
    subs = [d for d in d_ if d in classes] or subj_types(p_)
    objs = [r for r in r_ if r in classes] or obj_types(p_)
    poly = "" if (d_ and r_) else " ✱"
    for s_ in subs:
        for o_ in objs:
            key = (nid(s_), ln(p_), nid(o_))
            if key not in drawn: drawn.add(key); lines.append(E(nid(s_), nid(o_), ln(p_) + poly))
mm(lines)

# ----------------------------------------------------------------------------- class profiles
sec_prof = section("Class semantic profiles — all classes")
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.")
w()
for c in classes:
    t = term_by_iri.get(str(c), {})
    w(f"### {sec_prof}.{classes.index(c)+1} `{PFX}:{ln(c)}` — {lab(c)}"); w()
    w(f"**Definition.** {defn(c) or '—'}"); w()
    anc = ancestors(c); desc_ = descendants(c)
    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 '—'}")
    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 ""))
    lineage = [c] + anc
    dsurf = [p_ for p_ in dprops if any(d in lineage for d in dom[p_])]
    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, []))]
    outs = [p_ for p_ in oprops if any(d in lineage for d in dom[p_])]
    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)
    ins = [p_ for p_ in oprops if any(r in lineage for r in rng[p_])]
    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)
    def fmt(ps, inh=True):
        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 "—"
    w(f"- **Datatype surface:** {fmt(dsurf)}" + (f" · used polymorphically: {fmt(dsurf_poly, False)}" if dsurf_poly else ""))
    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 ""))
    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 ""))
    skb = [(p_, sorted(prop_schemes[p_], key=ln)) for p_ in outs + outs_poly if prop_schemes.get(p_)]
    w("- **SKOS bindings:** " + ("; ".join(f"`{ln(p_)}` → " + ", ".join('`'+ln(s)+'`' for s in ss) for p_, ss in skb) or "—"))
    shp = [r for cc in lineage for r in shape_by_class.get(cc, [])]
    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)"))
    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)))]
    w(f"- **Decisions:** {', '.join(decs_c) or '—'}")
    insts = instances_of.get(c, [])
    w(f"- **Individuals ({len(insts)}):** " + (", ".join(f"`{ln(i)}`" for i in insts) or "— (none in fixtures)"))
    cqc = sorted(cq_touch.get(c, set()) | {cid for p_ in outs + ins for cid in cq_touch.get(p_, set())})
    w(f"- **Competency questions:** {', '.join(cqc) or '—'}"); w()
    if outs or outs_poly or ins or ins_poly or insts:
        in_nodes, in_edges, out_nodes, out_edges, sk_nodes, sk_edges, sh_nodes, sh_edges = [], [], [], [], [], [], [], []
        seen = set()
        for p_ in ins + ins_poly:
            for sc in ([d for d in dom[p_] if d in classes] or subj_types(p_)):
                k = ("i", ln(p_), ln(sc))
                if k in seen or sc == c: continue
                seen.add(k)
                if f"I_{nid(sc)}" not in {x[0] for x in in_nodes}: in_nodes.append((f"I_{nid(sc)}", sc))
                in_edges.append(E(f"I_{nid(sc)}", "C", ln(p_) + ("" if dom[p_] else " ✱")))
        for p_ in outs + outs_poly:
            tgt = rng[p_][0] if rng[p_] else None
            if tgt is not None and tgt == SKOS.Concept:
                for s in sorted(prop_schemes.get(p_, []), key=ln):
                    k = ("o", ln(p_), ln(s))
                    if k in seen: continue
                    seen.add(k)
                    if nid(s) not in {x[0] for x in sk_nodes}: sk_nodes.append((nid(s), s))
                    sk_edges.append(E("C", nid(s), ln(p_)))
            else:
                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)
                if not targets: targets = obj_types(p_)
                for tg in targets:
                    k = ("o", ln(p_), ln(tg))
                    if k in seen: continue
                    seen.add(k)
                    if f"O_{nid(tg)}" not in {x[0] for x in out_nodes}: out_nodes.append((f"O_{nid(tg)}", tg))
                    out_edges.append(E("C", f"O_{nid(tg)}", ln(p_) + ("" if rng[p_] else " ✱")))
        for j, r in enumerate(shp):
            sid = f"SH_{nid(r[0])}_{j}"
            sh_nodes.append(N(sid, f"{esc(ln(r[0]))}<br/>{constraint_label(r)}", "shacl")); sh_edges.append(E("C", sid, "", True))
        L = ["flowchart LR"]
        if in_nodes:
            L.append('  subgraph IN["Incoming"]'); L += ["  " + class_node(i_, sc) for i_, sc in in_nodes]; L.append("  end")
        surface = "<br/>".join(f"{esc(ln(p_))} : {esc(ln(rng[p_][0])) if rng[p_] else 'literal'}" for p_ in (dsurf + dsurf_poly))
        L.append(N("C", f"<b>{esc(ln(c))}</b>" + (f"<br/>{surface}" if surface else ""), "focalart" if is_art(c) else "focal"))
        if out_nodes:
            L.append('  subgraph OUTG["Outgoing targets"]'); L += ["  " + class_node(i_, tg) for i_, tg in out_nodes]; L.append("  end")
        if sk_nodes:
            L.append('  subgraph SK["SKOS schemes"]'); L += ["  " + N(i_, esc(ln(s)), "skos") for i_, s in sk_nodes]; L.append("  end")
        if sh_nodes:
            L.append('  subgraph SHG["SHACL (operational, not OWL)"]'); L += ["  " + x for x in sh_nodes]; L.append("  end")
        if insts:
            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")
        L += in_edges + out_edges + sk_edges + sh_edges + [E(f"F_{nid(ind)}", "C", "rdf:type", True) for ind in insts]
        mm(L)

# ----------------------------------------------------------------------------- property registers
section("Object-property register")
w("| Property | Domain | Range | Uses in fixtures | Consumed SKOS schemes | CQs | Definition | Term · support · evidence |"); w("|---|---|---|---|---|---|---|---|")
for p_ in oprops:
    t = term_by_iri.get(str(p_), {}); n = sum(1 for _ in I.triples((None, p_, None)))
    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 [])} |")
w()
section("Datatype-property register")
w("| Property | Domain | Range | Uses in fixtures | SHACL | Definition | Term · support · evidence |"); w("|---|---|---|---|---|---|---|")
for p_ in dprops:
    t = term_by_iri.get(str(p_), {}); n = sum(1 for _ in I.triples((None, p_, None)))
    sh = "; ".join(f"{ln(r[0])}{card(r[3], r[4])} {r[5]}" for r in shape_rows if r[2] == p_) or "—"
    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 [])} |")
w()

# ----------------------------------------------------------------------------- SKOS
if schemes:
    section("SKOS vocabularies — overview, one view per scheme, all concepts")
    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.")
    w()
    L = ["flowchart LR"] + [N(nid(s), f"<b>{esc(ln(s))}</b>", "skos") for s in schemes]
    declared = set()
    for p_, ss in sorted(prop_schemes.items(), key=lambda kv: ln(kv[0])):
        for s in sorted(ss, key=ln):
            srcs_ = [d for d in dom[p_] if d in classes]
            for st in (srcs_ or subj_types(p_)):
                src_id = f"P_{nid(st)}"
                if src_id not in declared: declared.add(src_id); L.append(class_node(src_id, st))
                L.append(E(src_id, nid(s), ln(p_) + ("" if srcs_ else " ✱")))
    mm(L)
    for s in schemes:
        w(f"### `{ln(s)}` — {lab(s, T)}"); w(); w(defn(s) or "—"); w()
        cons = [p_ for p_, ss in prop_schemes.items() if s in ss]
        w(f"Consumed by: {', '.join('`'+ln(p_)+'`' for p_ in sorted(cons, key=ln)) or '— (declared, not yet consumed in fixtures)'}"); w()
        cs = [c for c in concepts if s in concept_scheme[c]]
        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]
        L += [E(nid(c), nid(s), "topConceptOf" if (c, SKOS.topConceptOf, s) in T else "inScheme") for c in cs]
        for c in cs:
            for b in T.objects(c, SKOS.broader):
                if b in cs: L.append(E(nid(c), nid(b), "broader"))
        mm(L)
        w("| Concept | prefLabel | Definition | Top concept | Referenced by individuals | Term · evidence |"); w("|---|---|---|---|---|---|")
        for c in cs:
            t = term_by_iri.get(str(c), {}); refs = sorted({ln(x) for x in I.subjects(None, c)})
            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 [])} |")
        w()

# ----------------------------------------------------------------------------- SHACL
if shapes:
    section("SHACL overlay — one unit per shape (operational, not OWL)")
    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.")
    w()
    for sh in shapes:
        tc = list(S.objects(sh, SH.targetClass))
        w(f"#### `{ln(sh)}`" + (f" → `{ln(tc[0])}`" if tc else "")); w()
        L = ["flowchart LR", N(nid(sh), esc(ln(sh)), "shacl")]
        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))
        for j, r in enumerate([r for r in shape_rows if r[0] == sh]):
            cid_ = f"{nid(sh)}_c{j}"
            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))
        mm(L)
    w("| Shape | Target class | Path | min | max | datatype/class | allowed values | message |"); w("|---|---|---|---|---|---|---|---|")
    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]} |")
    w()
    shr = PROJ / "validation/shacl-results.json"
    if shr.exists():
        try: w(f"SHACL executed with `pyshacl`: **conforms = {json.load(open(shr, encoding='utf-8')).get('conforms')}** (`validation/shacl-results.json`).")
        except Exception: pass
    else: w("SHACL conformance: not executed (no `validation/shacl-results.json`).")
    w()

# ----------------------------------------------------------------------------- instance threads
def thread_edges(nodes):
    return [(n, p_, o_) for n in nodes for p_, o_ in I.predicate_objects(n) if p_ in oprops and isinstance(o_, URIRef)]
def draw_thread_view(nodes, edges, all_nodes=True):
    L = ["flowchart LR"]; used = {e[0] for e in edges} | {e[2] for e in edges}; declared = set()
    for n in nodes:
        if all_nodes or n in used: declared.add(n); L.append(ind_node(nid(n), n))
    for s_, p_, o_ in edges:
        if o_ not in declared:
            declared.add(o_); L.append(N(nid(o_), esc(lab(o_, T)), "skos") if o_ in concepts else ind_node(nid(o_), o_))
    return L + [E(nid(s_), nid(o_), ln(p_)) for s_, p_, o_ in edges]
def auto_views(edges, limit):
    """Deterministic predicate bucketing: predicates by usage (desc, then name) greedily packed into views of ≤ limit edges."""
    cnt = Counter(ln(e[1]) for e in edges); preds = sorted(cnt, key=lambda k: (-cnt[k], k))
    views, cur, cur_n = [], [], 0
    for pr in preds:
        if cur and cur_n + cnt[pr] > limit: views.append(cur); cur, cur_n = [], 0
        cur.append(pr); cur_n += cnt[pr]
    if cur: views.append(cur)
    return [(f"view {i+1} of {len(views)}", "Partition by predicate (automatic, size-bounded).", v) for i, v in enumerate(views)]
def thread(sec_no, idx, title, cls_list, note="", views=None, auto=True):
    nodes = sorted({n for c in cls_list for n in instances_of.get(c, [])}, key=ln)
    if not nodes: return
    edges = thread_edges(nodes)
    w(f"### {sec_no}.{idx} {title}"); w()
    if note: w(note); w()
    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)
    if not views: mm(draw_thread_view(nodes, edges))
    else:
        covered = set()
        for k, (vt, vnote, preds) in enumerate(views):
            ve = [e for e in edges if ln(e[1]) in set(preds)]; covered |= set(ve)
            w(f"#### {sec_no}.{idx}{chr(97+k)} {vt}"); w()
            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()
            mm(draw_thread_view(nodes, ve, all_nodes=(k == 0)))
        missing = set(edges) - covered
        assert not missing, f"thread '{title}': assertions not covered by any view: {[(ln(a), ln(b), ln(c_)) for a, b, c_ in missing]}"
    w("| Individual | Type | Literal properties |"); w("|---|---|---|")
    for n in nodes:
        lits = "; ".join(f"{ln(p_)} = {str(o_)}" for p_, o_ in I.predicate_objects(n) if isinstance(o_, Literal) and p_ != RDFS.label)
        w(f"| `{ln(n)}` | {', '.join(ln(t) for t in I.objects(n, RDF.type))} | {lits or '—'} |")
    w()
if individuals:
    sec_thr = section("Instance threads — every individual")
    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).")
    w()
    cfg = yaml.safe_load(args.threads.read_text(encoding="utf-8")) if args.threads and args.threads.exists() else None
    if cfg and cfg.get("threads"):
        for i, th in enumerate(cfg["threads"], 1):
            cls_list = [URIRef(BASE + x) for x in th.get("classes") or []]
            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
            # configured threads are drawn whole unless they declare views or opt into `auto_partition: true`
            thread(sec_thr, i, th.get("title", f"Thread {i}"), cls_list, th.get("note", ""), views, auto=bool(th.get("auto_partition", False)))
    else:
        i = 0
        for r in roots:
            fam = [r] + descendants(r)
            if any(instances_of.get(c) for c in fam): i += 1; thread(sec_thr, i, f"{lab(r)} family", fam)

# ----------------------------------------------------------------------------- timeline
DATE_TYPES = {XSD.date, XSD.dateTime, XSD.gYear, XSD.gYearMonth}
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))
if dated:
    section("Timeline (all dated individuals)")
    L = ["timeline", f"    title {TITLE} — dated fixture individuals"]
    by_year = defaultdict(list)
    for d, s_, p_ in dated: by_year[d[:4]].append((d, s_, p_))
    for y in sorted(by_year):
        L.append(f"    section {y}")
        for d, s_, p_ in by_year[y]:
            # Mermaid timeline uses ':' as syntax, so ISO time/offset colons must
            # be normalised in the diagram label. The adjacent table retains the
            # exact RDF lexical value.
            timeline_label = d.replace(":", "-")
            L.append(f"        {timeline_label} : {esc(lab(s_)).replace(':', ' -')} ({p_})")
    mm(L)
    w("| Date | Subject | Property | Type |"); w("|---|---|---|---|")
    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))} |")
    w()

# ----------------------------------------------------------------------------- CQs
if cqs:
    section("Competency questions — full queries, graph paths and results")
    for cq in cqs:
        cid = cq.get("id"); m = next((x for x in qman if x.get("cq_id") == cid), None); r = cqres.get(cid, {})
        w(f"### {cid} ({cq.get('priority','—')}) — {cq.get('question','')}"); w()
        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 ""))
        touched_c = sorted((c for c, s in cq_touch.items() if cid in s and c in classes), key=ln)
        touched_p = sorted((p_ for p_, s in cq_touch.items() if cid in s and p_ in oprops + dprops), key=ln)
        w(f"- **Classes traversed:** {', '.join('`'+ln(c)+'`' for c in touched_c) or '—'} · **Properties:** {', '.join('`'+ln(p_)+'`' for p_ in touched_p) or '—'}"); w()
        if cid in cq_text: w("```sparql"); OUT.extend(cq_text[cid].rstrip().splitlines()); w("```"); w()
        if r.get("actual"):
            cols = list(r["actual"][0].keys())
            w("Actual result rows (from `validation/cq-results.json`):"); w(); w("| " + " | ".join(cols) + " |"); w("|" + "---|" * len(cols))
            for row in r["actual"]: w("| " + " | ".join(str(row.get(k, "")).replace(BASE, PFX + ":") for k in cols) + " |")
            w()
        L = ["flowchart LR", N("Q", cid, "prov")]
        for c in touched_c: L.append(class_node(nid(c), c)); L.append(E("Q", nid(c), "anchors on", True))
        for p_ in touched_p:
            d_ = ln(dom[p_][0]) if dom[p_] else "subject ✱"; r_ = ln(rng[p_][0]) if rng[p_] else "value ✱"
            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"))
            L.append(E(f"{nid(p_)}_d", f"{nid(p_)}_r", ln(p_))); L.append(E("Q", f"{nid(p_)}_d", "traverses", True))
        mm(L)

# ----------------------------------------------------------------------------- traceability
section("Traceability — source → evidence → term → RDF → fixture → CQ")
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.")
w()
L = ["flowchart LR"]; seen = set()
for c in classes:
    t = term_by_iri.get(str(c), {})
    if not t.get("evidence"): continue
    cn = nid(c); L.append(N(cn, f'{esc(ln(c))}<br/>{t.get("id","")}', "art" if is_art(c) else "owl"))
    for e in t["evidence"]:
        en = f"E_{re.sub(r'[^A-Za-z0-9_]', '_', e)}"
        if en not in seen: seen.add(en); L.append(N(en, e, "prov"))
        L.append(E(en, cn, "supports", True))
        sid = (ev_by_id.get(e) or {}).get("source_id")
        if sid:
            sn = f"S_{re.sub(r'[^A-Za-z0-9_]', '_', sid)}"
            if sn not in seen: seen.add(sn); L.append(N(sn, sid, "prov"))
            if (sn, en) not in seen: seen.add((sn, en)); L.append(E(sn, en, "cited by", True))
    for cid in sorted(cq_touch.get(c, [])):
        qn = f"Q_{re.sub(r'[^A-Za-z0-9_]', '_', cid)}"
        if qn not in seen: seen.add(qn); L.append(N(qn, cid, "prov"))
        L.append(E(cn, qn, "answers", True))
if len(L) > 1: mm(L)
else: w("No class carries evidence references in `model.yaml`."); w()

# ----------------------------------------------------------------------------- registers
def cell(x): return str(x if x is not None else "").replace("|", "/").replace("\n", " ").strip()
section("Source register (all sources, full metadata)")
w("| ID | Title | Type | Authority | Status | Publisher | Date | Version | Location | Notes |"); w("|---|---|---|---|---|---|---|---|---|---|")
for s in srcs: w("| " + " | ".join(cell(s.get(k)) for k in ("id", "title", "source_type", "authority", "status", "publisher", "date", "version", "location", "notes")) + " |")
w()
section("Evidence register (all records, full claims — nothing truncated)")
w("| ID | Source | Locator | Support | Confidence | Full claim | Supports | Notes |"); w("|---|---|---|---|---|---|---|---|")
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'))} |")
w()
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()
section("Decision register (full rationale)")
for d in decs:
    w(f"### {d.get('id')} — {d.get('topic','')} → `{d.get('decision','')}` ({d.get('status','')}, {d.get('date','')})"); w()
    w(str(d.get("rationale") or "").strip()); w()
    w(f"Evidence: {', '.join(d.get('evidence') or []) or '—'} · Affects: {', '.join(d.get('affects') or []) or '—'}"); w()
if not decs: w("No decisions recorded."); w()

section("Mappings, patterns and unresolved issues")
w("### External mappings (kept at asserted strength)"); w()
w("| ID | Local | Relation | External | Status | Rationale |"); w("|---|---|---|---|---|---|")
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'))} |")
w()
if model.get("mappings"):
    L = ["flowchart LR"]
    for m in model["mappings"]:
        loc = resolve(m.get("local", "")); ex_ = str(m.get("external", "")).rsplit("#", 1)[-1].rsplit("/", 1)[-1]
        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"))
        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))
    mm(L)
w("### Modelling patterns"); w()
for pt in model.get("patterns") or []:
    decisions = ", ".join(pt.get("decisions") or []) or "—"
    w(f"- **{pt.get('id')} {pt.get('name','')}** — {pt.get('description','')} Decisions / requirements: {decisions}.")
if not model.get("patterns"): w("—")
w(); w("### Issues and resolutions"); w()
for i in model.get("unresolved_issues") or []:
    w(f"#### {i.get('id')} ({i.get('status','')}) — {i.get('question','')}")
    w()
    w(f"- **Authority analysis:** {i.get('authority_analysis','—')}")
    w(f"- **Resolution:** {i.get('resolution','—')}")
    w(f"- **Evidence for / against:** {', '.join(i.get('evidence_for') or []) or '—'} / {', '.join(i.get('evidence_against') or []) or '—'}")
    w(f"- **Affects:** {', '.join(i.get('affects') or []) or '—'}")
    w()
if not model.get("unresolved_issues"): w("— (none recorded)")
w(); w("### Accepted axioms (model ledger)"); w()
w("| ID | Type | Subject | Object / statement | Support · evidence | Rationale |"); w("|---|---|---|---|---|---|")
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 '—'} |")
w()

# ----------------------------------------------------------------------------- formal subclass appendix
section("Formal subclass appendix (pure OWL taxonomy — every rdfs:subClassOf)")
L = ["classDiagram"]
for c in classes:
    for p_ in parent[c]: L.append(f"  {ln(p_)} <|-- {ln(c)}")
for c in classes:
    if not parent[c] and not children.get(c): L.append(f"  class {ln(c)}")
mm(L)

# ----------------------------------------------------------------------------- validation report
section("Atlas validation report")
w("### Inputs parsed")
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'}")
w(); w("### Coverage (mechanical, `scripts/validate_atlas.py`)")
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.")
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")
w(); w("### Semantic integrity")
w("- SHACL nodes drawn as rounded rectangles on dashed edges, never as OWL nodes; cardinalities labelled as SHACL and stated as not OWL: PASS")
w("- SKOS concepts drawn as stadiums, never as subclasses: PASS")
w("- Solid edges = asserted predicates / subClassOf; dashed = provenance, SHACL, rdf:type-of-fixture, interpretive: PASS")
w("- Focal class of each profile visually dominant (heavy outline); neighbours neutral: PASS")
w("- Fixture facts confined to the instance-thread/timeline sections and the labelled `Fixture individuals` groups of the class profiles: PASS")
w("- External mappings shown at asserted strength; proposed/disputed material and open issues surfaced from the ledgers: PASS")
w(); w("### Renderer validation")
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.")
if render_report:
    for tag, rr in (render_report.get("configs") or {}).items():
        w(f"- **Headless Chrome render, {tag}:** {rr.get('rendered')}/{rr.get('total')} blocks rendered to SVG; failures: {rr.get('failures') or 'none'}.")
    if render_report.get("identical_layouts") is not None:
        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.")
    w("- Report: `scripts/render_check.py` output embedded via `--render-report`.")
else:
    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.")
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`).")
w(); w("### Release caveat")
w("This atlas documents the project bundle and does not itself change the ontology release state (gates in `validation/`).")
w()

# ----------------------------------------------------------------------------- write
contents = "## Contents\n\n" + "\n".join(f"{i}. {t}" for i, t in enumerate(SECTIONS, 1))
text = ("\n".join(OUT) + "\n").replace("@@CONTENTS@@", contents).replace("@@NBLOCKS@@", str(OUT.count("```mermaid")))
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(text, encoding="utf-8")
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")
