Skip to content

zettelkasten.tables_schema_matrix

zettelkasten.tables_schema_matrix

Schema fingerprinting + schema-grouped comparison matrix construction.

Mechanically split out of tables.py: column_key_fingerprint (+ the deprecated spine_schema_fingerprint alias), spine-schema grouping, the spine_group_matrix producer, and schema/dimension derivation + column suggestion helpers.

column_key_fingerprint

column_key_fingerprint(org: dict[str, Any]) -> tuple[str, ...]

A spine org's SCHEMA identity: its sorted set of column keys.

Two spines belong to the same schema iff they expose the same dimension-tag set — regardless of per-spine labels or synthesis-graph names. This is the alignment key for grouping independent strands into a matrix.

Source code in zettelkasten/tables_schema_matrix.py
def column_key_fingerprint(org: dict[str, Any]) -> tuple[str, ...]:
    """A spine org's SCHEMA identity: its sorted set of column keys.

    Two spines belong to the same schema iff they expose the same dimension-tag
    set — regardless of per-spine labels or synthesis-graph names. This is the
    alignment key for grouping independent strands into a matrix.
    """
    keys = {str(c.get("key") or "").strip() for c in (org.get("columns") or [])}
    keys.discard("")
    return tuple(sorted(keys))

group_spines_by_spine_schema

group_spines_by_spine_schema(orgs: list[dict[str, Any]]) -> list[dict[str, Any]]

Group state == 'spine' orgs by schema fingerprint into matrix entries.

Only a fingerprint shared by >= 2 spines becomes a group (a matrix); a singleton schema stays an individual spine and is NOT grouped. Each group: {id, fingerprint, label, dimensions, members: [org_id], count}. id is a deterministic "schema:<hash>" (so the frontend can round-trip it); label is the members' common apex-title suffix, else a dimension-count fallback.

Source code in zettelkasten/tables_schema_matrix.py
def group_spines_by_spine_schema(orgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Group ``state == 'spine'`` orgs by schema fingerprint into matrix entries.

    Only a fingerprint shared by >= 2 spines becomes a group (a matrix); a
    singleton schema stays an individual spine and is NOT grouped. Each group:
    ``{id, fingerprint, label, dimensions, members: [org_id], count}``. ``id`` is a
    deterministic ``"schema:<hash>"`` (so the frontend can round-trip it); ``label``
    is the members' common apex-title suffix, else a dimension-count fallback.
    """
    by_fp: dict[tuple[str, ...], list[dict[str, Any]]] = {}
    for o in orgs:
        if o.get("state") != "spine":
            continue
        fp = column_key_fingerprint(o)
        if fp:
            by_fp.setdefault(fp, []).append(o)
    groups: list[dict[str, Any]] = []
    for fp, members in by_fp.items():
        if len(members) < 2:
            continue
        members = sorted(
            members, key=lambda m: str(m.get("title") or m.get("id") or "").lower()
        )
        titles = [str(m.get("title") or m.get("id") or "") for m in members]
        suffix = _common_title_suffix(titles)
        label = suffix or f"{len(fp)} dimensions ({len(members)} spines)"
        fp_id = "schema:" + hashlib.sha1("\x00".join(fp).encode("utf-8")).hexdigest()[:12]
        groups.append(
            {
                "id": fp_id,
                "fingerprint": list(fp),
                "label": label,
                "dimensions": list(fp),
                "members": [str(m.get("id") or "") for m in members],
                "count": len(members),
            }
        )
    groups.sort(key=lambda g: g["label"].lower())
    return groups

spine_group_matrix

spine_group_matrix(get_graph: GetGraph, localize: 'Callable[[str], str] | None' = None, *, members: list[dict[str, Any]], dim_keys: list[str], title: str = '', with_node_refs: bool = False) -> dict[str, Any]

Build the EPHEMERAL comparison matrix that unites same-schema spines.

Rows = the member spines' apexes (topics); columns = the shared schema dimensions (dim_keys); each cell carries the topic's dimension-node synthesized content as value (falling back to the joined evidence titles when the node has no body yet) plus its rolled-up evidence members for the drill-down. PURE read, no writes; the output mirrors :func:build_matrix so the frontend renders it unchanged. Each row also carries spine_ref/org_id so a click can drill into that topic's own spine.

Source code in zettelkasten/tables_schema_matrix.py
def spine_group_matrix(
    get_graph: GetGraph,
    localize: "Callable[[str], str] | None" = None,
    *,
    members: list[dict[str, Any]],
    dim_keys: list[str],
    title: str = "",
    with_node_refs: bool = False,
) -> dict[str, Any]:
    """Build the EPHEMERAL comparison matrix that unites same-schema spines.

    Rows = the member spines' apexes (topics); columns = the shared schema
    dimensions (``dim_keys``); each cell carries the topic's dimension-node
    synthesized content as ``value`` (falling back to the joined evidence titles
    when the node has no body yet) plus its rolled-up evidence members for the
    drill-down. PURE read, no writes; the output mirrors :func:`build_matrix` so the
    frontend renders it unchanged. Each row also carries ``spine_ref``/``org_id`` so
    a click can drill into that topic's own spine.
    """
    loc = localize or (lambda s: s)

    cols = normalize_columns(
        [
            {
                "key": k,
                "label": k.replace("-", " ").strip(),
                "type": "extractive",
                "backing": "schema_tag",
                "ref": k,
            }
            for k in dim_keys
        ]
    )

    graph_cache: dict[str, Any] = {}

    def _resolve(name: str) -> Any:
        zg = graph_cache.get(name)
        if zg is None:
            try:
                zg = get_graph(loc(name))
            except Exception:  # noqa: BLE001 — an unreadable member graph is skipped, not fatal
                zg = False
            graph_cache[name] = zg
        return zg or None

    # First pass: resolve each member spine's apex (its row identity + label). The
    # row-label suffix is derived from the APEX titles (e.g. "X: Decision Spec" ->
    # "Decision Spec"), NOT the org titles, so each row reads as the bare topic.
    resolved: list[tuple[dict[str, Any], Any, str, str]] = []
    for org in members:
        spine_graph = str(org.get("spine_ref") or "").strip()
        if not spine_graph:
            continue
        sg = _resolve(spine_graph)
        if sg is None:
            continue
        apex_id, apex_title = _spine_apex(sg)
        resolved.append((org, sg, apex_id, apex_title))

    apex_suffix = _common_title_suffix([rt for _, _, _, rt in resolved if rt])

    rows: list[dict[str, Any]] = []
    for org, sg, apex_id, apex_title in resolved:
        spine_graph = str(org.get("spine_ref") or "").strip()
        row_label = (
            _strip_title_suffix(apex_title, apex_suffix)
            or apex_title
            or str(org.get("title") or "")
            or spine_graph
        )
        colmap = {
            str(c.get("key") or "").strip(): str(c.get("apex") or "").strip()
            for c in (org.get("columns") or [])
        }
        relation = ""
        for c in org.get("columns") or []:
            if c.get("relation"):
                relation = str(c["relation"]).strip()
                break
        membership = _spine_membership_index(
            get_graph, loc, spine_graph, rollup=True, exclude_apex=True
        )
        cells: dict[str, Any] = {}
        for k in dim_keys:
            node_id = colmap.get(k, "")
            dim_note = sg.notes.get(node_id) if node_id else None
            uids = membership.get(node_id, set()) if node_id else set()
            mlist: list[dict[str, Any]] = []
            for uid in sorted(uids):
                home, _, nid = uid.partition("::")
                zg2 = _resolve(home)
                if zg2 is None:
                    continue
                n2 = zg2.notes.get(nid)
                if n2 is None:
                    continue
                mlist.append(_note_member(zg2, n2, relation or _RELATION_TAG))
            # A scaffold (un-authored placeholder) body is NOT a real summary:
            # treat it as empty so the cell reads its member titles (or a synthesized
            # paragraph, once materialized) instead of "… Source claims attach here."
            raw_body = (dim_note.body or "").strip() if dim_note is not None else ""
            # The BODY is the source of truth for "is this an un-authored placeholder":
            # a node may carry a real authored body while its status lags, so key the
            # blanking on the placeholder text itself, not on synthesis_status.
            summary = "" if _is_scaffold_body(raw_body) else raw_body
            if not summary and not mlist:
                cells[k] = _gap_cell()
            else:
                cell = _cell(mlist, summary=summary or None, provenance=_PROVENANCE_SELF)
                # Stamp the backing spine node so the synthesizer can author this
                # cell's paragraph and persist it onto the node (stripped from the
                # payload before it goes on the wire / to disk — see _extract_node_refs).
                if with_node_refs and node_id:
                    cell["_node_ref"] = {"graph": spine_graph, "node_id": node_id}
                cells[k] = cell
        rows.append(
            {
                "id": f"{spine_graph}::{apex_id}" if apex_id else spine_graph,
                "label": row_label,
                "source_graph": spine_graph,
                "spine_ref": spine_graph,
                "org_id": str(org.get("id") or ""),
                "cells": cells,
            }
        )
    rows.sort(key=lambda r: r["label"].lower())
    return {
        "table_id": "schema-matrix",
        "title": title or apex_suffix,
        "columns": cols,
        "row_axis": normalize_row_axis({"kind": "group", "strategy": "link"}),
        "rows": rows,
        "synthesis": {},
        "signature": "",
        "schema_version": 1,
        "spine_id": DEFAULT_SPINE_ID,
        # A schema matrix is materialized at view-time and always carries its rows,
        # so it is "populated" for render purposes (the empty/Generate states never
        # apply) — but it is ephemeral and never persisted.
        "exists": True,
        "ephemeral": True,
    }

suggest_columns

suggest_columns(*, doc_type: str = '') -> dict[str, Any]

Propose typed, backed columns, deterministic sources first.

Returns grouped candidates the UI renders as pickable column presets
  • schemas — each extraction-schema rubric's dimensions become schema_tag columns (deterministic; e.g. the lit-review-matrix rubric). This is the primary, "free" source.
  • templates / spinesschemas split by whether the schema declares a synthesis block: templates are flat column sets, spines also materialize a persistent graph (apex + dimension nodes).
  • note_types — the canonical note-type vocabulary (deterministic).
  • ccc — the CCC slot columns (deterministic).

Every candidate carries a deterministic flag so the UI can show what fills without an LLM.

Source code in zettelkasten/tables_schema_matrix.py
def suggest_columns(*, doc_type: str = "") -> dict[str, Any]:
    """Propose typed, backed columns, deterministic sources first.

    Returns grouped candidates the UI renders as pickable column presets:
      * ``schemas`` — each extraction-schema rubric's dimensions become
        ``schema_tag`` columns (deterministic; e.g. the ``lit-review-matrix``
        rubric). This is the primary, "free" source.
      * ``templates`` / ``spines`` — ``schemas`` split by whether the schema
        declares a ``synthesis`` block: templates are flat column sets, spines
        also materialize a persistent graph (apex + dimension nodes).
      * ``note_types`` — the canonical note-type vocabulary (deterministic).
      * ``ccc`` — the CCC slot columns (deterministic).
    Every candidate carries a ``deterministic`` flag so the UI can show what fills
    without an LLM.
    """
    from zettelkasten import extraction_schemas
    from zettelkasten.graph import VALID_TYPES

    # Schemas split two ways by their ``synthesis`` block: a schema WITHOUT one is
    # a flat *template* (just column definitions); WITH one it is a *spine* that
    # also materializes a persistent graph structure (apex + dimension nodes) and
    # pairs naturally with the ``persona`` row axis. ``schemas`` keeps the merged
    # list for back-compat; ``templates`` / ``spines`` are the split views the
    # wizard renders as distinct preset groups.
    schemas: list[dict[str, Any]] = []
    templates: list[dict[str, Any]] = []
    spines: list[dict[str, Any]] = []
    for sname in extraction_schemas.list_schemas():
        try:
            expanded = extraction_schemas.expand_schema(sname)
        except ValueError:
            continue
        if not expanded:
            continue
        dims = [
            {
                "key": _slug(d["tag"]),
                "label": d["tag"].replace("-", " "),
                "type": "extractive",
                "backing": "schema_tag",
                "ref": d["tag"],
                "prompt": d.get("desc", ""),
                "deterministic": True,
            }
            for d in expanded.get("dimensions", [])
        ]
        is_spine = expanded.get("synthesis") is not None
        entry = {
            "name": sname,
            "description": expanded.get("description", ""),
            "columns": dims,
            "synthesis": is_spine,
        }
        schemas.append(entry)
        (spines if is_spine else templates).append(entry)

    note_types = [
        {
            "key": t,
            "label": t,
            "type": "extractive",
            "backing": "note_type",
            "ref": t,
            "prompt": "",
            "deterministic": True,
        }
        for t in sorted(VALID_TYPES)
    ]

    ccc = [
        {
            "key": slot,
            "label": slot,
            "type": "extractive",
            "backing": "ccc_slot",
            "ref": slot,
            "prompt": "",
            "deterministic": True,
        }
        for slot in _CCC_SLOTS
    ]

    identity = [
        {"key": "source", "label": "Source", "type": "identity", "backing": "identity", "ref": "title", "prompt": "", "deterministic": True},
        {"key": "year", "label": "Year", "type": "identity", "backing": "identity", "ref": "year", "prompt": "", "deterministic": True},
        # Note-field columns — deterministic, fill on the ``note`` (entity) row axis
        # where each row's meta carries the note's own fields.
        {"key": "note-type", "label": "Type", "type": "categorical", "backing": "identity", "ref": "type", "prompt": "", "deterministic": True},
        {"key": "note-tags", "label": "Tags", "type": "extractive", "backing": "identity", "ref": "tags", "prompt": "", "deterministic": True},
        {"key": "note-body", "label": "Body", "type": "extractive", "backing": "identity", "ref": "body", "prompt": "", "deterministic": True},
    ]

    return {
        "identity": identity,
        "schemas": schemas,
        "templates": templates,
        "spines": spines,
        "note_types": note_types,
        "ccc": ccc,
        "doc_type": doc_type,
    }

derive_dimension_tags

derive_dimension_tags(columns: list[dict[str, Any]]) -> list[tuple[dict[str, Any], str]]

Ordered (column, dimension_tag) pairs for the MATERIALIZABLE columns.

THE single source of truth for a spine dimension's tag, shared by BOTH the spine materializer (organizations._promote_locked stamps this tag on the materialized dimension NODE) and :func:schema_from_columns (which embeds the same tag in the org's extraction rubric). Deriving them from one function is what guarantees the embedded-schema tags are IDENTICAL to the materialized dimension-node tags — otherwise a promoted spine's schema silently disagrees with its own graph (the phase-3 silent-loss trap).

Rule: tag = (ref or key).strip() — the column's ref (the dimension tag it already reads) when present, else its durable key (matching what build_spine_skeleton keys the node on). Tags are then de-duplicated with a -2/-3 suffix: two columns sharing a ref would otherwise emit the same tag, which extraction_schemas.expand_spec rejects (tags must be unique within a schema — they become note tags). Because the SAME dedup is applied to the spine nodes (they consume these pairs too), the node tag and the schema tag move together and can never diverge. The materializable filter mirrors promotion's: identity/metadata columns and any materialize: false opt-out are skipped.

EMPTY-TAG RULE: a materializable column whose derived base tag is empty or whitespace-only (blank ref AND blank key) is SKIPPED entirely. Such a tag is rejected by extraction_schemas.expand_spec (so the embedded schema would silently drop it), while the spine materializer would still stamp a tag='' node — leaving the node and its own embedded schema in disagreement (the phase-3 silent-loss trap). Skipping here is the shared truth both consumers see, so the dimension is omitted from BOTH the materialized node set and the generated schema consistently, rather than half-dropped. On the normal promote path this skip is now BELT-AND-SUSPENDERS: normalize_columns guarantees a non-empty key (it strips an explicit key and falls back to the label slug for a blank/whitespace one), so a normalized column can only reach the skip via a genuinely blank ref AND key — i.e. an un-normalized / hand-built column list — which this guard still handles correctly.

Source code in zettelkasten/tables_schema_matrix.py
def derive_dimension_tags(
    columns: list[dict[str, Any]],
) -> list[tuple[dict[str, Any], str]]:
    """Ordered ``(column, dimension_tag)`` pairs for the MATERIALIZABLE columns.

    THE single source of truth for a spine dimension's tag, shared by BOTH the
    spine materializer (``organizations._promote_locked`` stamps this tag on the
    materialized dimension NODE) and :func:`schema_from_columns` (which embeds the
    same tag in the org's extraction rubric). Deriving them from one function is
    what guarantees the embedded-schema tags are IDENTICAL to the materialized
    dimension-node tags — otherwise a promoted spine's schema silently disagrees
    with its own graph (the phase-3 silent-loss trap).

    Rule: ``tag = (ref or key).strip()`` — the column's ``ref`` (the dimension tag
    it already reads) when present, else its durable ``key`` (matching what
    ``build_spine_skeleton`` keys the node on). Tags are then de-duplicated with a
    ``-2``/``-3`` suffix: two columns sharing a ``ref`` would otherwise emit the
    same tag, which ``extraction_schemas.expand_spec`` rejects (tags must be unique
    within a schema — they become note tags). Because the SAME dedup is applied to
    the spine nodes (they consume these pairs too), the node tag and the schema tag
    move together and can never diverge. The materializable filter mirrors
    promotion's: identity/metadata columns and any ``materialize: false`` opt-out
    are skipped.

    EMPTY-TAG RULE: a materializable column whose derived base tag is empty or
    whitespace-only (blank ``ref`` AND blank ``key``) is SKIPPED entirely. Such a
    tag is rejected by ``extraction_schemas.expand_spec`` (so the embedded schema
    would silently drop it), while the spine materializer would still stamp a
    ``tag=''`` node — leaving the node and its own embedded schema in disagreement
    (the phase-3 silent-loss trap). Skipping here is the shared truth both
    consumers see, so the dimension is omitted from BOTH the materialized node set
    and the generated schema consistently, rather than half-dropped. On the normal
    promote path this skip is now BELT-AND-SUSPENDERS: ``normalize_columns``
    guarantees a non-empty ``key`` (it strips an explicit key and falls back to the
    label slug for a blank/whitespace one), so a normalized column can only reach
    the skip via a genuinely blank ``ref`` AND ``key`` — i.e. an un-normalized /
    hand-built column list — which this guard still handles correctly.
    """
    out: list[tuple[dict[str, Any], str]] = []
    seen: set[str] = set()
    for c in columns or []:
        if not isinstance(c, dict):
            continue
        backing = str(c.get("backing") or "").strip()
        if backing == "identity" or c.get("materialize") is False:
            continue
        base_tag = str(c.get("ref") or "").strip() or str(c.get("key") or "").strip()
        # A blank base tag (no usable ref AND no usable key) cannot become a valid
        # dimension tag — drop it so the spine node and the embedded schema agree.
        if not base_tag:
            continue
        tag = base_tag
        n = 2
        while tag in seen:
            tag = f"{base_tag}-{n}"
            n += 1
        seen.add(tag)
        out.append((c, tag))
    return out

schema_from_columns

schema_from_columns(columns: list[dict[str, Any]], *, title: str, synthesis_graph: str, attach_relation: str, apex_relation: str, apex_type: str, apex_title: str, row_axis: 'dict[str, Any] | None' = None) -> dict[str, Any]

Generate a RAW extraction-schema spec from a matrix lens's columns.

The inverse of :func:suggest_columns (which derives matrix columns from a schema's dimensions): this turns the interactive matrix builder's column set back into a real extraction schema so a promoted spine can embed its own rubric (design §5 — "the builder's output is reified into a real schema").

Each materializable column becomes one schema dimension. The tag is the SHARED dimension tag from :func:derive_dimension_tags ((ref or key) with -2/-3 dedup) — the EXACT tag the spine materializer stamps on the matching dimension node — so the embedded schema can never diverge from the materialized graph. Only the ancillary fields differ by backing:

  • schema_tagdesc = the column's prompt.
  • note_typenote_type = ref (the note type the scribe writes), desc = the column label.
  • ccc_slotdesc = label or ref.
  • promptdesc = the column prompt.

TAG CONTRACT: the tag is (ref or key).strip(), NOT a slug of the human label. A prompt column (no ref) therefore tags on its durable key — identical to the spine node's tag — rather than a lossy label slug, so a relabel never re-keys the dimension. The original label/prompt still survives as desc. See :func:derive_dimension_tags for the dedup rule.

identity/metadata columns are SKIPPED (they are display fields, not extraction dimensions) — mirroring promotion's materializable filter; a column explicitly opting out with materialize: false is skipped too.

The synthesis block makes the schema spine-backed: graph = synthesis_graph (the apex graph the spine materializes into), the apex node carries apex_type/apex_title, and dimension_node records the spine's ACTUAL attach_relation (how member notes wire to a dimension node) and apex_relation (how a dimension rolls up to the apex). row_axis is accepted for signature symmetry with the builder's config but is not part of the generated rubric (the dimension/apex relations come from the spine, not the row grouping).

Returns a RAW spec ready for :func:extraction_schemas.expand_spec; validates by expanding it (raising ValueError on a malformed result) — expand_spec is imported lazily to avoid the tablesextraction_schemas import cycle.

Source code in zettelkasten/tables_schema_matrix.py
def schema_from_columns(
    columns: list[dict[str, Any]],
    *,
    title: str,
    synthesis_graph: str,
    attach_relation: str,
    apex_relation: str,
    apex_type: str,
    apex_title: str,
    row_axis: "dict[str, Any] | None" = None,
) -> dict[str, Any]:
    """Generate a RAW extraction-schema spec from a matrix lens's columns.

    The inverse of :func:`suggest_columns` (which derives matrix columns from a
    schema's dimensions): this turns the interactive matrix builder's column set
    back into a real extraction schema so a promoted spine can embed its own
    rubric (design §5 — "the builder's output is reified into a real schema").

    Each materializable column becomes one schema dimension. The ``tag`` is the
    SHARED dimension tag from :func:`derive_dimension_tags` (``(ref or key)`` with
    ``-2``/``-3`` dedup) — the EXACT tag the spine materializer stamps on the
    matching dimension node — so the embedded schema can never diverge from the
    materialized graph. Only the ancillary fields differ by ``backing``:

    * ``schema_tag`` — ``desc`` = the column's ``prompt``.
    * ``note_type``  — ``note_type`` = ``ref`` (the note type the scribe writes),
      ``desc`` = the column ``label``.
    * ``ccc_slot``   — ``desc`` = ``label or ref``.
    * ``prompt``     — ``desc`` = the column ``prompt``.

    TAG CONTRACT: the tag is ``(ref or key).strip()``, NOT a slug of the human
    ``label``. A ``prompt`` column (no ``ref``) therefore tags on its durable
    ``key`` — identical to the spine node's tag — rather than a lossy label slug,
    so a relabel never re-keys the dimension. The original label/prompt still
    survives as ``desc``. See :func:`derive_dimension_tags` for the dedup rule.

    ``identity``/metadata columns are SKIPPED (they are display fields, not
    extraction dimensions) — mirroring promotion's materializable filter; a column
    explicitly opting out with ``materialize: false`` is skipped too.

    The ``synthesis`` block makes the schema spine-backed: ``graph`` =
    ``synthesis_graph`` (the apex graph the spine materializes into), the apex
    ``node`` carries ``apex_type``/``apex_title``, and ``dimension_node`` records
    the spine's ACTUAL ``attach_relation`` (how member notes wire to a dimension
    node) and ``apex_relation`` (how a dimension rolls up to the apex). ``row_axis``
    is accepted for signature symmetry with the builder's config but is not part
    of the generated rubric (the dimension/apex relations come from the spine, not
    the row grouping).

    Returns a RAW spec ready for :func:`extraction_schemas.expand_spec`; validates
    by expanding it (raising ``ValueError`` on a malformed result) — ``expand_spec``
    is imported lazily to avoid the ``tables`` ↔ ``extraction_schemas`` import cycle.
    """
    del row_axis  # accepted for signature symmetry; not part of the rubric.
    # Tags come from the SHARED derivation so they are identical to the spine
    # dimension-node tags; this also applies promotion's materializable filter.
    dims: list[dict[str, Any]] = []
    for c, tag in derive_dimension_tags(columns):
        backing = str(c.get("backing") or "").strip()
        label = str(c.get("label") or "").strip()
        ref = str(c.get("ref") or "").strip()
        prompt = str(c.get("prompt") or "").strip()
        if backing == "note_type":
            dim: dict[str, Any] = {"tag": tag, "note_type": ref, "desc": label}
        elif backing == "ccc_slot":
            dim = {"tag": tag, "desc": label or ref}
        else:  # ``schema_tag``, ``prompt`` (and any other member-bearing backing).
            dim = {"tag": tag, "desc": prompt}
        dims.append(dim)

    spec: dict[str, Any] = {
        "description": f"Schema generated from spine '{title}'.",
        "dimensions": dims,
        "synthesis": {
            "graph": synthesis_graph,
            "node": {"type": apex_type, "title_template": apex_title},
            "dimension_node": {
                "attach_relation": attach_relation,
                "relation": apex_relation,
            },
        },
    }

    # Validate the generated spec by expanding it (lazy import breaks the
    # tables <-> extraction_schemas/organizations import cycle).
    from zettelkasten import extraction_schemas

    extraction_schemas.expand_spec(title or synthesis_graph or "spine", spec)
    return spec