Skip to content

zettelkasten.outline.gather

zettelkasten.outline.gather

Outline GATHER helpers (deterministic bundle / section / spine machinery).

Moved verbatim from the former monolithic zettelkasten/outline.py as part of the package split: the pure helpers that :func:zettelkasten.outline.gather_outline_material composes into an :class:~zettelkasten.outline.materials.OutlineMaterial. No LLM, no draft, no persistence.

narrative_ordering

narrative_ordering(bundles: list[ClaimBundle], edges: list[tuple[str, str, str]], *, year_of: Callable[[str], int | None] | None = None, salience_of: Callable[[str], float] | None = None) -> list[ClaimBundle]

Order a section's units foundations-first, counterclaims nested at target.

A pure function over the bundles and their (src_uid, relation, dst_uid) structural edges. Ordering rules:

  • Foundations first. Lineage edges (depends-on / extends / derives-from / specializes / prerequisite-of / generalizes) establish a foundation→dependent partial order; a topological pass places every foundation before the units that build on it.
  • Chronological + salient within a tier. Among units with no ordering edge between them, the earlier (by year) comes first, then the more salient, then the uid — so the order is fully deterministic. Undated units sort last within their tier.
  • Counterclaims nested. A unit that contests another in-section unit (contradicts / responds-to / qualifies) is placed immediately after its target rather than floating to a tier of its own.

Cycles are broken safely (the smallest remaining unit by the tie-break key is emitted). Units absent from bundles are ignored. year_of / salience_of override the per-bundle attributes (handy for tests).

Source code in zettelkasten/outline/gather.py
def narrative_ordering(
    bundles: list[ClaimBundle],
    edges: list[tuple[str, str, str]],
    *,
    year_of: Callable[[str], int | None] | None = None,
    salience_of: Callable[[str], float] | None = None,
) -> list[ClaimBundle]:
    """Order a section's units foundations-first, counterclaims nested at target.

    A pure function over the bundles and their ``(src_uid, relation, dst_uid)``
    structural edges. Ordering rules:

    * **Foundations first.** Lineage edges (``depends-on`` / ``extends`` /
      ``derives-from`` / ``specializes`` / ``prerequisite-of`` / ``generalizes``)
      establish a foundation→dependent partial order; a topological pass places
      every foundation before the units that build on it.
    * **Chronological + salient within a tier.** Among units with no ordering
      edge between them, the earlier (by ``year``) comes first, then the more
      salient, then the uid — so the order is fully deterministic. Undated units
      sort last within their tier.
    * **Counterclaims nested.** A unit that contests another in-section unit
      (``contradicts`` / ``responds-to`` / ``qualifies``) is placed immediately
      after its target rather than floating to a tier of its own.

    Cycles are broken safely (the smallest remaining unit by the tie-break key is
    emitted). Units absent from ``bundles`` are ignored. ``year_of`` /
    ``salience_of`` override the per-bundle attributes (handy for tests).
    """
    uid_to_item: dict[str, ClaimBundle] = {}
    for b in bundles:
        uid_to_item[b.uid] = b
    uid_set = set(uid_to_item)

    def _year(uid: str) -> int:
        if year_of is not None:
            y = year_of(uid)
        else:
            y = getattr(uid_to_item[uid], "year", None)
        return y if y is not None else _UNDATED_YEAR

    def _sal(uid: str) -> float:
        if salience_of is not None:
            return salience_of(uid)
        return getattr(uid_to_item[uid], "salience", 0.0)

    def _key(uid: str) -> tuple[int, float, str]:
        return (_year(uid), -_sal(uid), uid)

    # Counter units (src contests dst, both in section) are nested, not primary.
    counter_target: dict[str, str] = {}
    for s, rel, d in edges:
        if rel in COUNTER_RELATIONS and s in uid_set and d in uid_set and s != d:
            counter_target.setdefault(s, d)

    primary = [u for u in uid_to_item if u not in counter_target]
    primary_set = set(primary)

    deps: dict[str, set[str]] = {u: set() for u in primary}
    dependents: dict[str, set[str]] = {u: set() for u in primary}
    for s, rel, d in edges:
        side = _LINEAGE_FOUNDATION_SIDE.get(rel)
        if side is None:
            continue
        foundation, dependent = (d, s) if side == "dst" else (s, d)
        if foundation in primary_set and dependent in primary_set and foundation != dependent:
            if foundation not in deps[dependent]:
                deps[dependent].add(foundation)
                dependents[foundation].add(dependent)

    indeg = {u: len(deps[u]) for u in primary}
    ready = sorted([u for u in primary if indeg[u] == 0], key=_key)
    order: list[str] = []
    placed: set[str] = set()
    while ready:
        u = ready.pop(0)
        order.append(u)
        placed.add(u)
        newly = []
        for dep in dependents[u]:
            indeg[dep] -= 1
            if indeg[dep] == 0:
                newly.append(dep)
        if newly:
            ready = sorted(ready + newly, key=_key)
    # Any units left (a lineage cycle) are emitted by the same tie-break.
    for u in sorted((u for u in primary if u not in placed), key=_key):
        order.append(u)

    counters_by_target: dict[str, list[str]] = {}
    for c, tgt in counter_target.items():
        counters_by_target.setdefault(tgt, []).append(c)

    result: list[str] = []
    emitted: set[str] = set()

    def _emit(u: str) -> None:
        # Place a unit, then DEPTH-FIRST place its counters (and their counters)
        # so a chain ``a responds-to b responds-to c`` nests as ``c, b, a`` —
        # each counter lands immediately after its target. ``emitted`` guards
        # against cycles and re-emission (a counter is never placed twice).
        if u in emitted:
            return
        result.append(u)
        emitted.add(u)
        for c in sorted(counters_by_target.get(u, [])):
            _emit(c)

    for u in order:
        _emit(u)
    # Counters whose target chain was never placed (target missing, or a cycle
    # among counters) trail in a stable order.
    for c in sorted(counter_target):
        _emit(c)

    return [uid_to_item[u] for u in result if u in uid_to_item]