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