Skip to content

zettelkasten.outline.materials

zettelkasten.outline.materials

Outline serializable structures + shared vocabularies.

Moved verbatim from the former monolithic zettelkasten/outline.py as part of the package split. Pure data layer: the JSON-serializable GATHER-result dataclasses, the slot/unit vocabularies they use, and the leaf-coercion helper. No LLM, no I/O.

QuoteEvidence dataclass

A verbatim quote note (RETRIEVED) or an explicit no-quote marker.

When present is True the row carries the quote note's verbatim body plus source.page and the note id so the frontend can resolve and the integrity pass can verify it. When present is False it is the explicit 'no quote on file' marker (everything else empty) — never a fabricated quote.

Source code in zettelkasten/outline/materials.py
@dataclass
class QuoteEvidence:
    """A verbatim quote note (RETRIEVED) or an explicit no-quote marker.

    When ``present`` is ``True`` the row carries the quote note's verbatim
    ``body`` plus ``source.page`` and the note ``id`` so the frontend can resolve
    and the integrity pass can verify it. When ``present`` is ``False`` it is the
    explicit ``'no quote on file'`` marker (everything else empty) — never a
    fabricated quote.
    """

    id: str
    graph: str
    title: str
    body: str
    page: Any
    grounded: bool
    relation: str
    present: bool = True
    marker: str = ""

CCCSkeleton dataclass

A single paper's CCC evidence card: deterministic slots, empty generative.

The DETERMINISTIC fields are filled from the graph — the citation label, the :func:syllabus.situate / :func:syllabus.why_read badges, the definition / mechanism / numbers note slots, the quotes from this paper, and note_ids for everything referenced. The generative block is left EMPTY for the Wave-3 agent to draft, and gaps records every empty slot so nothing is a silent omission.

Source code in zettelkasten/outline/materials.py
@dataclass
class CCCSkeleton:
    """A single paper's CCC evidence card: deterministic slots, empty generative.

    The DETERMINISTIC fields are filled from the graph — the citation label, the
    :func:`syllabus.situate` / :func:`syllabus.why_read` ``badges``, the
    ``definition`` / ``mechanism`` / ``numbers`` note slots, the ``quotes`` from
    this paper, and ``note_ids`` for everything referenced. The ``generative``
    block is left EMPTY for the Wave-3 agent to draft, and ``gaps`` records every
    empty slot so nothing is a silent omission.
    """

    paper_id: str
    source_graph: str | None
    title: str
    citation: str
    year: int | None
    authors: list[str]
    owned: bool
    role: str
    relation: str
    importance: float
    badges: dict[str, Any]
    definition: list[dict[str, Any]] = field(default_factory=list)
    mechanism: list[dict[str, Any]] = field(default_factory=list)
    numbers: list[dict[str, Any]] = field(default_factory=list)
    quotes: list[QuoteEvidence] = field(default_factory=list)
    note_ids: list[str] = field(default_factory=list)
    generative: dict[str, str] = field(default_factory=dict)
    gaps: list[dict[str, Any]] = field(default_factory=list)

ClaimBundle dataclass

The grounded material for one outline unit (a claim, or a fallback note).

Carries the unit's text + id, its supporting-paper CCC cards (stronger on top), its counterclaims (each WITH its own evidence) and caveats, the core paper card, its own verbatim quotes (or marker), the claim-to-claim narrative bridges + reading path, and the explicit gaps. note_ids lists every note this bundle references so the integrity pass / frontend can resolve them. source is "claim" for a claim-layer unit or "fallback" for a claim-sparse source-note unit; the shape is identical either way.

Source code in zettelkasten/outline/materials.py
@dataclass
class ClaimBundle:
    """The grounded material for one outline unit (a claim, or a fallback note).

    Carries the unit's text + id, its supporting-paper CCC cards (stronger on
    top), its counterclaims (each WITH its own evidence) and caveats, the core
    paper card, its own verbatim quotes (or marker), the claim-to-claim narrative
    bridges + reading path, and the explicit ``gaps``. ``note_ids`` lists every
    note this bundle references so the integrity pass / frontend can resolve them.
    ``source`` is ``"claim"`` for a claim-layer unit or ``"fallback"`` for a
    claim-sparse source-note unit; the shape is identical either way.
    """

    uid: str
    id: str
    graph: str
    title: str
    type: str
    text: str
    status: str
    strength: float
    salience: float
    year: int | None
    theme: str
    source: str
    supporting: list[CCCSkeleton] = field(default_factory=list)
    core_paper: CCCSkeleton | None = None
    counterclaims: list[dict[str, Any]] = field(default_factory=list)
    caveats: list[dict[str, Any]] = field(default_factory=list)
    quotes: list[QuoteEvidence] = field(default_factory=list)
    bridges: list[dict[str, Any]] = field(default_factory=list)
    reading_path: list[dict[str, Any]] = field(default_factory=list)
    note_ids: list[str] = field(default_factory=list)
    paper_ids: list[str] = field(default_factory=list)
    gaps: list[dict[str, Any]] = field(default_factory=list)

ConceptTag dataclass

A condensed reference to a framing CONCEPT note for a section.

A concept FRAMES a section (it names the lens, not an argument), so it is surfaced as a compact CITATION TAG under the section's intro — a [chip] plus a short label — rather than as a full claim-style unit. uid is the graph-qualified chip token the scaffold cites verbatim (present in material.note_ids so the integrity pass resolves it); citation is the home paper's deterministic Author (Year) label. Landscape concepts are NOT tags — they are section headers (the landscape theme tier).

Source code in zettelkasten/outline/materials.py
@dataclass
class ConceptTag:
    """A condensed reference to a framing CONCEPT note for a section.

    A concept FRAMES a section (it names the lens, not an argument), so it is
    surfaced as a compact CITATION TAG under the section's intro — a ``[chip]``
    plus a short label — rather than as a full claim-style unit. ``uid`` is the
    graph-qualified chip token the scaffold cites verbatim (present in
    ``material.note_ids`` so the integrity pass resolves it); ``citation`` is the
    home paper's deterministic ``Author (Year)`` label. Landscape concepts are
    NOT tags — they are section headers (the landscape theme tier).
    """

    uid: str
    id: str
    graph: str
    label: str
    citation: str
    paper_ids: list[str] = field(default_factory=list)

OutlineSection dataclass

A themed section: a theme, its ordered claim bundles, and framing concepts.

concepts are condensed framing tags (see :class:ConceptTag) rendered under the section intro — distinct from claims, which are the full units.

level is the markdown header depth the section renders at (2 = ##, the flat/single-spine default). tier is "leaf" for a normal claim-bearing section (the flat default) or "ancestor" for a nested composition-tier framing section (see :func:_resolve_nested_ancestors): an ancestor tier carries no claim bundles — the DRAFT agent synthesizes 1–2 "lecture-note" framing bullets from its subtree. Both fields default to the flat values, so a non-nested outline (ancestor_levels == 0) is byte-identical to before.

Source code in zettelkasten/outline/materials.py
@dataclass
class OutlineSection:
    """A themed section: a theme, its ordered claim bundles, and framing concepts.

    ``concepts`` are condensed framing tags (see :class:`ConceptTag`) rendered
    under the section intro — distinct from ``claims``, which are the full units.

    ``level`` is the markdown header depth the section renders at (``2`` = ``## ``,
    the flat/single-spine default). ``tier`` is ``"leaf"`` for a normal
    claim-bearing section (the flat default) or ``"ancestor"`` for a nested
    composition-tier framing section (see :func:`_resolve_nested_ancestors`): an
    ancestor tier carries no claim bundles — the DRAFT agent synthesizes 1–2
    "lecture-note" framing bullets from its subtree. Both fields default to the
    flat values, so a non-nested outline (``ancestor_levels == 0``) is
    byte-identical to before.
    """

    theme_id: str
    label: str
    source: str
    claims: list[ClaimBundle] = field(default_factory=list)
    concepts: list[ConceptTag] = field(default_factory=list)
    gaps: list[dict[str, Any]] = field(default_factory=list)
    level: int = 2
    tier: str = "leaf"

OutlineMaterial dataclass

The full deterministic GATHER result for an outline scope.

sections are the themed groups (in :func:syllabus.theme_model order), unplaced the bundles whose unit sits in no theme, gaps the outline-level gap markers, note_ids the union of every resolvable note ref any bundle references — each a graph-qualified "<graph>::<id>" token (see :func:_qualify) so a chip names its EXACT source graph and resolves regardless of the review scope — and paper_ids the union of the work/source-graph ids the bundles reference (bare folder names like "alpha", NOT note ids) — kept in a SEPARATE field so the integrity pass never tries to resolve a paper id as a note, while the frontend can still resolve every claim, paper, and quote without re-walking the graph.

Source code in zettelkasten/outline/materials.py
@dataclass
class OutlineMaterial:
    """The full deterministic GATHER result for an outline scope.

    ``sections`` are the themed groups (in :func:`syllabus.theme_model` order),
    ``unplaced`` the bundles whose unit sits in no theme, ``gaps`` the
    outline-level gap markers, ``note_ids`` the union of every *resolvable note
    ref* any bundle references — each a graph-qualified ``"<graph>::<id>"`` token
    (see :func:`_qualify`) so a chip names its EXACT source graph and resolves
    regardless of the review scope — and ``paper_ids`` the union of the
    work/source-graph ids the bundles reference (bare folder names like
    ``"alpha"``, NOT note ids) — kept in a SEPARATE field so the integrity pass
    never tries to resolve a paper id as a note, while the frontend can still
    resolve every claim, paper, and quote without re-walking the graph.
    """

    scope: dict[str, Any]
    theme_source: str
    sections: list[OutlineSection] = field(default_factory=list)
    unplaced: list[ClaimBundle] = field(default_factory=list)
    gaps: list[dict[str, Any]] = field(default_factory=list)
    note_ids: list[str] = field(default_factory=list)
    paper_ids: list[str] = field(default_factory=list)
    # Structured explicit signal for the NESTED composition (see
    # :func:`_resolve_nested_ancestors`): present ONLY when a nesting selection
    # was requested (an explicit ``ancestor_uids`` set OR a non-zero
    # ``ancestor_levels``). ``None`` on a flat outline, and dropped from
    # :meth:`to_dict` so a flat build's payload stays byte-identical.
    nesting_degradation: "dict[str, Any] | None" = None

    def to_dict(self) -> dict[str, Any]:
        """A plain, guaranteed ``json.dumps``-able dict of the whole material tree.

        Runs :func:`dataclasses.asdict` and then coerces any non-JSON-native
        leaf to a string — most importantly a ``source.page`` that a YAML
        author wrote as a bare ISO date (parsed to ``datetime.date``), which
        ``asdict`` preserves and ``json.dumps`` would otherwise reject.

        The nested-composition :class:`OutlineSection` fields ``level``/``tier``
        are emitted ONLY for a section that is NOT the flat default (i.e.
        ``level != 2`` or ``tier != "leaf"``). A flat/single-spine section drops
        both keys entirely, so a non-nested build's payload — the DRAFT prompt,
        the cached artifact, every downstream consumer — is BYTE-IDENTICAL to
        before the nested mode existed (the ``ancestor_levels == 0`` no-op).
        """
        data = _json_safe(asdict(self))
        # A flat outline carries no nesting selection, so the degradation block is
        # ``None`` — drop the key entirely so the flat payload is byte-identical to
        # before the nested-composition mode (mirrors the level/tier drop below).
        if data.get("nesting_degradation") is None:
            data.pop("nesting_degradation", None)
        for sec in data.get("sections", []):
            if not isinstance(sec, dict):
                continue
            if sec.get("level") == 2 and sec.get("tier") == "leaf":
                sec.pop("level", None)
                sec.pop("tier", None)
        return data

    def project_section(
        self, section: "OutlineSection | None"
    ) -> "OutlineMaterial":
        """A single-slice :class:`OutlineMaterial` for ONE section (or the unplaced bucket).

        Returns a new material carrying ONLY ``section`` (with ``unplaced`` empty),
        or — when ``section`` is ``None`` — only the ``unplaced`` bundles (with
        ``sections`` empty), and with ``note_ids`` / ``paper_ids`` NARROWED to just
        the refs that slice actually cites. This bounds the per-call DRAFT payload
        so a large outline can be drafted section-by-section instead of stuffing the
        entire (multi-megabyte) material into one prompt and overflowing the draft
        model's context window (the failure that makes the agent lose its material
        and emit a "re-paste the GATHER material" refusal). The shared ``scope`` and
        ``theme_source`` are preserved verbatim so a projected section fingerprints
        (:func:`_section_material_signature`) identically to its slice in the whole
        material; ``gaps`` (outline-level) and ``nesting_degradation`` are dropped —
        they are whole-outline framing, not per-section content.
        """
        bundles = list(section.claims) if section is not None else list(self.unplaced)
        concepts = list(section.concepts) if section is not None else []
        note_ids: set[str] = set()
        paper_ids: set[str] = set()
        for b in bundles:
            note_ids.update(b.note_ids)
            paper_ids.update(b.paper_ids)
        for c in concepts:
            if c.uid:
                note_ids.add(c.uid)
            paper_ids.update(c.paper_ids)
        return OutlineMaterial(
            scope=self.scope,
            theme_source=self.theme_source,
            sections=[section] if section is not None else [],
            unplaced=[] if section is not None else bundles,
            gaps=[],
            note_ids=sorted(note_ids),
            paper_ids=sorted(paper_ids),
            nesting_degradation=None,
        )

to_dict

to_dict() -> dict[str, Any]

A plain, guaranteed json.dumps-able dict of the whole material tree.

Runs :func:dataclasses.asdict and then coerces any non-JSON-native leaf to a string — most importantly a source.page that a YAML author wrote as a bare ISO date (parsed to datetime.date), which asdict preserves and json.dumps would otherwise reject.

The nested-composition :class:OutlineSection fields level/tier are emitted ONLY for a section that is NOT the flat default (i.e. level != 2 or tier != "leaf"). A flat/single-spine section drops both keys entirely, so a non-nested build's payload — the DRAFT prompt, the cached artifact, every downstream consumer — is BYTE-IDENTICAL to before the nested mode existed (the ancestor_levels == 0 no-op).

Source code in zettelkasten/outline/materials.py
def to_dict(self) -> dict[str, Any]:
    """A plain, guaranteed ``json.dumps``-able dict of the whole material tree.

    Runs :func:`dataclasses.asdict` and then coerces any non-JSON-native
    leaf to a string — most importantly a ``source.page`` that a YAML
    author wrote as a bare ISO date (parsed to ``datetime.date``), which
    ``asdict`` preserves and ``json.dumps`` would otherwise reject.

    The nested-composition :class:`OutlineSection` fields ``level``/``tier``
    are emitted ONLY for a section that is NOT the flat default (i.e.
    ``level != 2`` or ``tier != "leaf"``). A flat/single-spine section drops
    both keys entirely, so a non-nested build's payload — the DRAFT prompt,
    the cached artifact, every downstream consumer — is BYTE-IDENTICAL to
    before the nested mode existed (the ``ancestor_levels == 0`` no-op).
    """
    data = _json_safe(asdict(self))
    # A flat outline carries no nesting selection, so the degradation block is
    # ``None`` — drop the key entirely so the flat payload is byte-identical to
    # before the nested-composition mode (mirrors the level/tier drop below).
    if data.get("nesting_degradation") is None:
        data.pop("nesting_degradation", None)
    for sec in data.get("sections", []):
        if not isinstance(sec, dict):
            continue
        if sec.get("level") == 2 and sec.get("tier") == "leaf":
            sec.pop("level", None)
            sec.pop("tier", None)
    return data

project_section

project_section(section: 'OutlineSection | None') -> 'OutlineMaterial'

A single-slice :class:OutlineMaterial for ONE section (or the unplaced bucket).

Returns a new material carrying ONLY section (with unplaced empty), or — when section is None — only the unplaced bundles (with sections empty), and with note_ids / paper_ids NARROWED to just the refs that slice actually cites. This bounds the per-call DRAFT payload so a large outline can be drafted section-by-section instead of stuffing the entire (multi-megabyte) material into one prompt and overflowing the draft model's context window (the failure that makes the agent lose its material and emit a "re-paste the GATHER material" refusal). The shared scope and theme_source are preserved verbatim so a projected section fingerprints (:func:_section_material_signature) identically to its slice in the whole material; gaps (outline-level) and nesting_degradation are dropped — they are whole-outline framing, not per-section content.

Source code in zettelkasten/outline/materials.py
def project_section(
    self, section: "OutlineSection | None"
) -> "OutlineMaterial":
    """A single-slice :class:`OutlineMaterial` for ONE section (or the unplaced bucket).

    Returns a new material carrying ONLY ``section`` (with ``unplaced`` empty),
    or — when ``section`` is ``None`` — only the ``unplaced`` bundles (with
    ``sections`` empty), and with ``note_ids`` / ``paper_ids`` NARROWED to just
    the refs that slice actually cites. This bounds the per-call DRAFT payload
    so a large outline can be drafted section-by-section instead of stuffing the
    entire (multi-megabyte) material into one prompt and overflowing the draft
    model's context window (the failure that makes the agent lose its material
    and emit a "re-paste the GATHER material" refusal). The shared ``scope`` and
    ``theme_source`` are preserved verbatim so a projected section fingerprints
    (:func:`_section_material_signature`) identically to its slice in the whole
    material; ``gaps`` (outline-level) and ``nesting_degradation`` are dropped —
    they are whole-outline framing, not per-section content.
    """
    bundles = list(section.claims) if section is not None else list(self.unplaced)
    concepts = list(section.concepts) if section is not None else []
    note_ids: set[str] = set()
    paper_ids: set[str] = set()
    for b in bundles:
        note_ids.update(b.note_ids)
        paper_ids.update(b.paper_ids)
    for c in concepts:
        if c.uid:
            note_ids.add(c.uid)
        paper_ids.update(c.paper_ids)
    return OutlineMaterial(
        scope=self.scope,
        theme_source=self.theme_source,
        sections=[section] if section is not None else [],
        unplaced=[] if section is not None else bundles,
        gaps=[],
        note_ids=sorted(note_ids),
        paper_ids=sorted(paper_ids),
        nesting_degradation=None,
    )