Skip to content

zettelkasten.outline.verify

zettelkasten.outline.verify

Outline integrity verifier + structural section editing + actionable fixes.

Moved verbatim from the former monolithic zettelkasten/outline.py as part of the package split: the deterministic post-pass (:func:verify_draft), the markdown section-slice helpers, and the per-violation Fix/Remove machinery. LLM-free (an extractor is injected lazily for a quote Fix).

Violation dataclass

A single integrity-post-pass finding against a drafted scaffold.

kind is one of "chip" (an unresolved [note-id] chip), "quote" (a quoted span not found verbatim in any referenced quote note), "header" (a claim header that traces to no claim/finding unit), or "structure" (the header skeleton drifted from the expected section headers — in a FLAT outline the ## themes; in a NESTED outline the composition tier tree, where each tier header is known by its (label, depth) — a missing/reordered tier, or a claim placed at a tier depth). ref is the offending token/text and line its 1-based line.

Source code in zettelkasten/outline/verify.py
@dataclass
class Violation:
    """A single integrity-post-pass finding against a drafted scaffold.

    ``kind`` is one of ``"chip"`` (an unresolved ``[note-id]`` chip),
    ``"quote"`` (a quoted span not found verbatim in any referenced quote note),
    ``"header"`` (a claim header that traces to no claim/finding unit), or
    ``"structure"`` (the header skeleton drifted from the expected section headers
    — in a FLAT outline the ``## `` themes; in a NESTED outline the composition
    tier tree, where each tier header is known by its ``(label, depth)`` — a
    missing/reordered tier, or a claim placed at a tier depth). ``ref`` is the
    offending token/text and ``line`` its 1-based line.
    """

    kind: str
    detail: str
    ref: str = ""
    line: "int | None" = None

verify_draft

verify_draft(markdown: str, material: OutlineMaterial) -> list[Violation]

Independently re-check a drafted scaffold against the gathered material.

Deterministic and LLM-free — it does NOT trust the agent to police itself (the skill contract is the drafting discipline; THIS is the verifier):

Code-fenced blocks and inline code spans are MASKED before scanning (see :func:_mask_code), so a scaffold that documents its own conventions — a header explaining the [graph::id] chip form, a "verbatim" example — does not self-trip the chip/quote scans. Reserved ALL-CAPS markers ([GAP], [TODO], induction-head [A]/[B] notation; see :func:_is_sentinel) are structural, not references, and are skipped by the chip scan entirely.

  • chips — every [note-id] chip must resolve to an id in material.note_ids (a note) or material.paper_ids (a paper); unresolved chips are flagged. Reserved sentinel markers are exempt.
  • quotes — every double-quoted span (scanned over the WHOLE document, so a multi-line span is inspected too; smart double-quotes are folded to straight first so a MISMATCHED-delimiter span — smart-open + straight-close, or the reverse — cannot evade the scan) must be a VERBATIM excerpt of a referenced quote note's body: whole-quote equality OR word-boundary containment of the span's tokens (see :func:_verbatim_contained), NOT a raw substring. A span that matches nothing — a fabrication, or a cherry-picked/recombined fragment that never ran contiguously — is flagged.
  • headers — a markdown header that carries a chip is a CLAIM header; if none of its chips trace to a claim/finding UNIT in the material it is flagged as untraceable. A header with no chip is structural (the honesty header, a theme/section title) and is not a claim header.

NON-GUARANTEE: this deterministic pass polices structural integrity (chip resolution, verbatim quoting, header traceability) — it does NOT police prose FAITHFULNESS. Fabricated declarative prose that carries only real [chip]s and no double-quoted spans passes every check here by construction; guarding against that is the DRAFT contract's job (the assemble-grounded-outline skill's restraint discipline), not the verifier's.

Returns the violations in document order; the caller marks them (e.g. :func:build_outline appends them to the artifact) rather than silently passing a draft that failed the check.

Source code in zettelkasten/outline/verify.py
def verify_draft(markdown: str, material: OutlineMaterial) -> list[Violation]:
    """Independently re-check a drafted scaffold against the gathered material.

    Deterministic and LLM-free — it does NOT trust the agent to police itself
    (the skill contract is the drafting discipline; THIS is the verifier):

    Code-fenced blocks and inline ``code`` spans are MASKED before scanning (see
    :func:`_mask_code`), so a scaffold that documents its own conventions — a
    header explaining the ``[graph::id]`` chip form, a ``"verbatim"`` example —
    does not self-trip the chip/quote scans. Reserved ALL-CAPS markers
    (``[GAP]``, ``[TODO]``, induction-head ``[A]``/``[B]`` notation; see
    :func:`_is_sentinel`) are structural, not references, and are skipped by the
    chip scan entirely.

    * **chips** — every ``[note-id]`` chip must resolve to an id in
      ``material.note_ids`` (a note) or ``material.paper_ids`` (a paper);
      unresolved chips are flagged. Reserved sentinel markers are exempt.
    * **quotes** — every double-quoted span (scanned over the WHOLE document, so
      a multi-line span is inspected too; smart double-quotes are folded to
      straight first so a MISMATCHED-delimiter span — smart-open + straight-close,
      or the reverse — cannot evade the scan) must be a VERBATIM excerpt of a
      referenced quote note's body: whole-quote equality OR word-boundary
      containment of the span's tokens (see :func:`_verbatim_contained`), NOT a
      raw substring. A span that matches nothing — a fabrication, or a
      cherry-picked/recombined fragment that never ran contiguously — is flagged.
    * **headers** — a markdown header that carries a chip is a CLAIM header; if
      none of its chips trace to a claim/finding UNIT in the material it is
      flagged as untraceable. A header with no chip is structural (the honesty
      header, a theme/section title) and is not a claim header.

    NON-GUARANTEE: this deterministic pass polices structural integrity (chip
    resolution, verbatim quoting, header traceability) — it does NOT police prose
    FAITHFULNESS. Fabricated declarative prose that carries only real ``[chip]``s
    and no double-quoted spans passes every check here by construction; guarding
    against that is the DRAFT contract's job (the ``assemble-grounded-outline``
    skill's restraint discipline), not the verifier's.

    Returns the violations in document order; the caller marks them (e.g.
    :func:`build_outline` appends them to the artifact) rather than silently
    passing a draft that failed the check.
    """
    note_ids = set(material.note_ids)
    paper_ids = set(material.paper_ids)
    resolvable = note_ids | paper_ids
    # Note refs are now graph-qualified ``graph::id`` tokens; paper ids stay bare
    # folder names. Tolerate a chip that dropped its ``graph::`` prefix (the agent
    # occasionally emits a bare ``[id]``) by also matching the bare id-part of any
    # resolvable token — the verifier polices fabrication, not graph precision.
    resolvable_bare = {_chip_bare_id(t) for t in resolvable}
    unit_ids = _material_unit_ids(material)
    unit_bare_ids = {_chip_bare_id(u) for u in unit_ids}
    quote_norms = _material_quote_norms(material)

    violations: list[Violation] = []

    # ── structure: no header may exceed markdown's 6-level maximum ──────────────
    # A line of 7+ ``#`` then a space is an UNPARSEABLE header: markdown supports at
    # most 6 heading levels, so ``_HEADING_RE`` (``#{1,6}``) never matches it and
    # ``_section_headings`` (with every tier/depth check that consumes it) is BLIND
    # to it. Without this explicit scan a fabricated ``####### X`` / ``####### Unplaced``
    # — even at the common 2-ancestor depth — would pass with ZERO violations (an
    # anti-fabrication bypass). Match ``_section_headings``' code-fence discipline so
    # a ``#######`` inside a ``` block (documentation, not a header) is not flagged.
    in_fence = False
    fence_char = ""
    for i, line in enumerate(markdown.splitlines()):
        fm = _FENCE_RE.match(line)
        if fm:
            tok = fm.group(1)
            if not in_fence:
                in_fence, fence_char = True, tok[0]
            elif line.strip()[:1] == fence_char:
                in_fence = False
            continue
        if in_fence:
            continue
        if _OVERDEEP_HEADING_RE.match(line):
            violations.append(Violation(
                kind="structure",
                detail=(
                    "header exceeds maximum depth (markdown supports at most 6 "
                    "levels) — a header with 7+ '#' is unparseable"
                ),
                ref=line.lstrip("#").strip(),
                line=i + 1,
            ))

    # ── structure: the ``## `` skeleton must be the author's SETTLED themes ─────
    # ``## `` headers are reserved for the settled theme labels (in
    # ``material.sections`` order); declarative CLAIM headers live at ``### ``.
    # This is what lets the frontend map a theme → its section by exact label and
    # never falls back to a fragile positional guess. A trailing ``## Unplaced``
    # bucket is allowed; everything else at ``## `` is a claim/topic in the wrong
    # place (or a renamed/reordered/missing theme), and is flagged.
    expected_labels = [s.label for s in material.sections]
    expected_levels = [getattr(s, "level", 2) or 2 for s in material.sections]
    # Nested mode is decided by the TIER signal, not depth: an outline is nested
    # iff it stacked at least one ``tier == "ancestor"`` framing tier. A depth test
    # (``lvl != 2``) MISSES a single kept ancestor over a dimensionless (leafless)
    # subject — the only section is the ancestor at level 2 — and would take the
    # flat branch, letting a shallow ``Unplaced`` pass unchecked. A true flat
    # outline has no ancestor tiers, so ``ancestor_count == 0`` and behavior (and
    # the byte-identical flat serialization) is unchanged.
    ancestor_count = sum(
        1 for s in material.sections
        if getattr(s, "tier", "leaf") == "ancestor"
    )
    nested = ancestor_count > 0
    if expected_labels and not nested:
        expected_set = set(expected_labels)
        h2_seq = [
            (i + 1, text)
            for (i, lvl, text) in _section_headings(markdown)
            if lvl == 2
        ]
        seen_theme: list[str] = []
        for lineno, text in h2_seq:
            if text in expected_set:
                seen_theme.append(text)
            elif text != "Unplaced":
                violations.append(Violation(
                    kind="structure",
                    detail=(
                        f"'## {text}' is not a settled theme header — a claim or "
                        "topic belongs at '### '; '## ' is reserved for the "
                        "author's themes"
                    ),
                    ref=text,
                    line=lineno,
                ))
        if seen_theme != expected_labels:
            missing = [lbl for lbl in expected_labels if lbl not in set(seen_theme)]
            if missing:
                detail = (
                    "scaffold is missing settled theme header(s): "
                    + "; ".join(missing)
                )
            else:
                detail = (
                    "settled theme headers are out of order — expected: "
                    + " | ".join(expected_labels)
                    + "; got: "
                    + " | ".join(seen_theme)
                )
            violations.append(Violation(kind="structure", detail=detail))
    elif expected_labels and nested:
        # NESTED composition: the ``## ``/``### ``/``#### ``… skeleton is the
        # composition tier tree, so a section header is known by (label, DEPTH) —
        # each tier must appear at its own markdown level (root shallowest), in
        # order. This is the ONLY hierarchy-aware rule; the GLOBAL chip/quote checks
        # below still police every tier (ancestor framing bullets included). A
        # trailing ``Unplaced`` bucket stays allowed AT THE LEAF DEPTH, where the
        # membership-unrouted claims live; an ``Unplaced`` header at a shallower
        # ancestor depth is a misplaced tier and is flagged. Header depths in the
        # tier-level band that match no tier — a flattened/misplaced tier or a
        # claim promoted to a header — are flagged.
        expected_pairs = list(zip(expected_labels, expected_levels))
        expected_pair_set = set(expected_pairs)
        # The TRUE leaf depth is the depth the subject's leaf partition occupies:
        # ``2 + <kept ancestor count>``. Compute it DIRECTLY from the ancestor
        # tiers (``ancestor_count`` above) rather than as ``max(expected_levels)`` —
        # the latter COLLAPSES onto the deepest ANCESTOR depth when the subject
        # materialized NO leaf sections (a dimensionless subject whose claims all
        # fell to Unplaced), which would falsely accept a shallow ``Unplaced`` and
        # flag the real leaf-depth one.
        leaf_depth = 2 + ancestor_count
        # The DEEPEST legal nested header: leaf sections sit at ``leaf_depth`` and
        # any claim sub-header the leaf partition emits is exactly ONE deeper. A
        # header BELOW this is a claim promoted too deep (or a bogus deep bucket)
        # and is flagged — the pre-fix scan band capped at the leaf depth and
        # silently skipped everything deeper, so a ``##### `` claim or ``##### ``
        # Unplaced slipped through UNSCANNED.
        deepest_legal = leaf_depth + 1
        seen_pairs: list[tuple[str, int]] = []
        seen_unplaced = False
        for (i, lvl, text) in _section_headings(markdown):
            if lvl < 2:
                continue
            if text == "Unplaced":
                # The Unplaced bucket holds LEAF content (membership-unrouted
                # claims in no dimension), so under nesting it renders at the LEAF
                # depth (``2 + <kept ancestor count>``) and EXACTLY ONCE. An
                # Unplaced header at any other depth is a misplaced tier; a second
                # one at the leaf depth is a duplicate bucket — both flagged.
                if lvl == leaf_depth and not seen_unplaced:
                    seen_unplaced = True
                    continue
                if lvl == leaf_depth:
                    detail = (
                        f"duplicate '{'#' * lvl} Unplaced' bucket — a nested "
                        "scaffold may carry at most ONE Unplaced bucket, at the "
                        f"leaf tier depth ('{'#' * leaf_depth} ')"
                    )
                else:
                    detail = (
                        f"'{'#' * lvl} Unplaced' is not at the leaf tier depth — "
                        "the Unplaced bucket holds leaf content and must sit at "
                        f"the leaf tier depth ('{'#' * leaf_depth} ')"
                    )
                violations.append(Violation(
                    kind="structure", detail=detail, ref=text, line=i + 1,
                ))
                continue
            if lvl > deepest_legal:
                violations.append(Violation(
                    kind="structure",
                    detail=(
                        f"'{'#' * lvl} {text}' is deeper than the deepest legal "
                        f"nested header ('{'#' * deepest_legal} ') — a claim "
                        "promoted too deep or a stray sub-header"
                    ),
                    ref=text,
                    line=i + 1,
                ))
                continue
            if lvl > leaf_depth:
                # A legal claim sub-header ONE level below the leaf tier; not a
                # composition tier, so it is exempt from tier matching. Its
                # grounding (if it carries a chip) is policed by the global
                # claim-header traceability check below.
                continue
            pair = (text, lvl)
            if pair in expected_pair_set:
                seen_pairs.append(pair)
            else:
                violations.append(Violation(
                    kind="structure",
                    detail=(
                        f"'{'#' * lvl} {text}' is not a composition-tier header at "
                        "this depth — every tier header must match a spine tier "
                        "label at its tier depth (root shallowest)"
                    ),
                    ref=text,
                    line=i + 1,
                ))
        if seen_pairs != expected_pairs:
            seen_set = set(seen_pairs)
            missing = [lbl for (lbl, lv) in expected_pairs if (lbl, lv) not in seen_set]
            if missing:
                detail = (
                    "scaffold is missing composition-tier header(s): "
                    + "; ".join(missing)
                )
            else:
                detail = (
                    "composition-tier headers are out of order — expected: "
                    + " | ".join(f"{'#' * lv} {lbl}" for lbl, lv in expected_pairs)
                    + "; got: "
                    + " | ".join(f"{'#' * lv} {lbl}" for lbl, lv in seen_pairs)
                )
            violations.append(Violation(kind="structure", detail=detail))

    # double-quoted spans are verbatim source text — scanned over the WHOLE
    # document (not per line) so a multi-line span is caught, with its 1-based
    # start line recovered for the violation. Smart double-quotes are folded to
    # straight FIRST so a mismatched-delimiter span (smart-open + straight-close,
    # or the reverse) is still inspected; the fold is length-preserving so byte
    # offsets still map back to the original document's line numbers.
    # NON-GUARANTEE: a quoted single common word that happens to appear verbatim
    # in some note body passes by construction — a known, low-value gap (the
    # token-containment test cannot distinguish a deliberate citation from an
    # incidental word match), not a faithfulness guarantee.
    # Code-fenced blocks and inline ``code`` spans are masked to spaces FIRST so
    # syntax the scaffold documents about itself (the conventions header's
    # ``[graph::id]`` template, a ``"verbatim"`` example) cannot self-trip the
    # chip/quote scans. Masking is length-preserving, so offsets/line numbers are
    # unchanged.
    masked = _mask_code(markdown)
    scan_md = masked.translate(_SMART_DOUBLE_QUOTES)
    for m in _QUOTE_RE.finditer(scan_md):
        span = m.group(1)
        norm = normalize_for_match(span)
        if not norm:
            continue
        if not any(_verbatim_contained(norm, qb) for qb in quote_norms):
            violations.append(Violation(
                kind="quote",
                detail="quoted span is not verbatim in any referenced quote note",
                ref=span,
                line=scan_md.count("\n", 0, m.start()) + 1,
            ))

    # chips + claim headers are line-oriented. Scan the MASKED lines (code spans
    # blanked) so documented syntax is skipped; the ORIGINAL line is kept only to
    # recover a faithful header ref. Splitting both on ``"\n"`` keeps them aligned
    # 1:1 with the offset-derived quote line numbers above.
    raw_lines = markdown.split("\n")
    masked_lines = masked.split("\n")
    for lineno, (line, mline) in enumerate(zip(raw_lines, masked_lines), start=1):
        # Reserved ALL-CAPS markers (GAP, TODO, A/B notation) are structural, not
        # references — they are neither resolved nor treated as claim-header chips.
        chips = [
            m.group(1) for m in _CHIP_RE.finditer(mline)
            if not _is_sentinel(m.group(1))
        ]

        # chips resolve to a real note or paper
        for chip in chips:
            if chip not in resolvable and _chip_bare_id(chip) not in resolvable_bare:
                violations.append(Violation(
                    kind="chip",
                    detail=f"chip '{chip}' resolves to no note or paper in the material",
                    ref=chip,
                    line=lineno,
                ))

        # claim headers trace to a unit
        stripped = line.lstrip()
        if stripped.startswith("#") and chips:
            if not any(
                c in unit_ids or _chip_bare_id(c) in unit_bare_ids for c in chips
            ):
                header_text = stripped.lstrip("#").strip()
                violations.append(Violation(
                    kind="header",
                    detail="claim header does not trace to a claim/finding unit in the material",
                    ref=header_text,
                    line=lineno,
                ))

    violations.sort(key=lambda v: (v.line or 0))
    return violations