Claim mining¶
Once an outline has themes, each theme needs claims — the grounded sentences that actually say something, each backed by evidence. The outline wizard offers four ways to populate a theme, and it is easy to conflate them. They differ on two axes: whether they reuse claims already in the corpus or draft new ones from full text, and how they gather their material.

Two strategies reuse claims that already exist (left); two synthesize new, grounded drafts from full text (right).
- Producer (writes + agentic synthesis):
zettelkasten/claim_producer.py - Outline UI (where you invoke them):
OutlineWizard.tsx - Related: Grounded extraction · Re-mine · Spines
The four strategies¶
1. Propose placements — sort what you already have¶
propose_placements is deterministic and read-only. It takes every claim
that is unplaced in the current board and homes each one into its nearest
existing theme by cosine similarity to that theme's claim centroid. A claim that
is not close enough to any theme stays unplaced rather than being force-fit. No
model is called; nothing new is written until you accept the placements. This is
the bulk "file the leftovers" move — best when the corpus already contains the
claims and you just need them sorted.

2. Find existing — retrieve corpus claims for one theme¶
suggest_claims (the wizard's Find existing button) is also retrieval, but
scoped to a single theme and driven by you. It ranks the corpus's existing
claims by similarity to the theme and shows the top matches with their scores;
you click the ones that belong. Where propose placements auto-sorts the whole
unplaced pool at once, find existing surfaces candidates for one theme so you
place them by hand.

3. Generate claims — synthesize new drafts from the theme's members¶
mine_claims_for_topic (the Generate claims button) is agentic. It reads
the full text of the notes already gathered under the theme and synthesizes
new grounded claim candidates for it — including counter-claims. These are
drafts: each proposed claim carries its supporting members, and nothing is
committed until you place it. Reach for this when the evidence is present in the
theme's own members but no one has written the summarizing claim yet.

4. Mine from sources — draft atomic claims from chosen papers¶
The Mine from sources flow is two steps. propose_mine_sources suggests
candidate source papers worth mining for the theme (papers not necessarily
already in it); then mine_atomic_claims reads the full text of the sources you
select and extracts atomic, grounded claim drafts from them. Unlike generate
claims, which works from material already in the theme, this pulls in new claims
from sources you deliberately bring in.

How they compare¶
| Strategy | Existing or new? | Scope | Agentic? | You do |
|---|---|---|---|---|
| Propose placements | Existing claims | Whole unplaced pool → all themes | No (vectors) | Review the auto-homing |
| Find existing | Existing claims | One theme | No (retrieval) | Pick from ranked matches |
| Generate claims | New drafts | The theme's own members | Yes | Place the drafts you like |
| Mine from sources | New drafts | Selected external source papers | Yes | Choose sources, place drafts |
The dialectical layer — contradictions, debates, and supersession¶
Populating themes with claims is only half the picture: a mature corpus also
disagrees with itself, and those tensions are where the field is live. Three
propose-only read functions in
zettelkasten/claims.py
surface that dialectic. Like the mining strategies above they write nothing —
they emit proposals, and a confirmed edge is only ever authored through the
gated claim producer (which honors ZK_PROPOSE_ONLY).
claim(action="discover_contradictions")finds candidate contradictions between the corpus's claims. It clusters the claim vectors for candidate recall (reusingpropose_clusters), forms candidate pairs, and applies three deterministic guards before any LLM call: a subject-overlap gate (the two claims must share a concept tag, a linked concept note, or a cited work), a near-duplicate exclusion (cosine ≥ ~0.92 is a paraphrase, not a contradiction), and a similarity band (contradictions live related-but-not-identical). Survivors go to a conservative, injectable, write-free stance classifier restricted tocontradicts/qualifies/responds-to/nonethat is told to answernoneunless the two claims make opposing assertions about the same thing. A pair is proposed only when classifiedcontradictsabove a confidence floor (default 0.6,ZK_CONTRADICTION_CONFIDENCE_FLOOR) and, by default, with reverse-order (A↔B) agreement (ZK_CONTRADICTION_REQUIRE_SYMMETRY). It is precision-first: we would rather miss a contradiction than assert a false one. The subject-overlap gate is a recall-narrowing pre-filter — it only decides which pairs are cheap enough to be worth classifying; the injectable stance classifier owns the finalcontradicts/noneverdict.claim(action="debate_map")aggregates the stance edges (discovered and authored) into a dashboard-ready tension graph: nodes are claims sized byclaim_strengthand colored byclaim_centrality; edges are the stance relations; camps are the connected components overcontradictsedges (ignoring self-loopcontradictsartifacts, which are not debates), each labelledcontested,resolved, orthin(the debate is under-evidenced) — the same dialectical logicanalyze_gapsuses. A camp isresolvedonly when one member is superseded by another member of the same camp: an externalsupersedesedge from a claim outside the debate does not resolve a genuinely contested camp.claim(action="find_supersessions")proposes belief-revisionsupersedesedges. B supersedes A when BcontradictsA and is stronger by a margin (default 0.15) and newer and their support sets overlap (a shared source or subject). Recency is judged on a single axis: if either side has a datable supporting-paper year the comparison is made on paper-years alone (a stronger-but-undated claim never supersedes an evidence-dated one on note-id), and only when neither side has a paper-year does it fall back to the note-id creation date. Self-loopcontradictsartifacts are skipped. Each proposal carries the strength and recency deltas that justify it, and is never auto-written.
The opportunity layer — scanning the negative space¶
The gap analyzer (syllabus(action="gaps") → claims.analyze_gaps) surfaces
weaknesses attached to existing claims — the six default families evidential,
dialectical, structural, coverage, comprehensiveness, and temporal. A
second, opt-in set of six families in the leaf module
zettelkasten/opportunities.py
turns the same engine into an opportunity finder by scanning the negative
space — the missing edges and unmatched nodes across the knowledge graph and
across the memory↔zettelkasten boundary. They are all read-only /
propose-only (a detector only returns a gap dict describing an opportunity and a
suggested action — it never writes), and are emitted only when named in the
gap_types filter, so the default scan is unchanged. To keep claims.py free
of a synapse import (which would re-create a circular import), analyze_gaps
does a lazy from zettelkasten import opportunities that fires only when one
of these families is requested.
asymmetry— a practice memory-node with no aligned canon claim, or a canon claim with no aligned practice node. Read off the cross-store claim overlay: it is a two-sided set difference between the practice nodes (get_memory_source) and the claims (build_claim_index) versus the endpoints the overlay actually links. Each gap's anchor names the unmatched node and which side is missing. An absent/empty overlay yields nothing.bridge— two dense clusters with (near-)zero connecting edges but high inter-cluster embedding similarity: a synthesis opportunity. Cluster-pair work is O(k²) over the small number of clusters (comparing centroids), never O(n²) over notes. The anchor carries a representative from each cluster so the frontend can draw a ghost edge.crux— an unresolved central debate: acontestedcamp from thedebate_map, ranked by strength × centrality. The action suggests resolving it via asupersedesdecision.orphaned_question— aquestionnote with no incoming answering edge (nosupports/responds-tobacklink). The action suggests answering it.void— a sparse-but-surrounded region of embedding space: a tiny cluster whose centroid hugs a dense one (an unasked question / underexplored area). Optionally named by an injected LLM, else a deterministic label.transfer— a method/model note applied within one thematic cluster that structurally fits an adjacent cluster where it has not been tried.
Each family registers an entry in GAP_TYPE_WEIGHTS and _GAP_ACTIONABILITY, so
its gaps carry the same bounded severity = salience × type-weight ×
actionability and rank alongside the claim-anchored families. Every detector is a
pure function of an already-built ClaimIndex plus explicit inputs (clusters,
vectors, the debate map, the overlay); the embedder/LLM is injected as an optional
parameter (default None) so the scan is deterministic and testable without a
model, and each detector returns [] rather than raising on a degenerate corpus
(empty box, no claims, no overlay, a single cluster).
Everything stays grounded¶
None of these strategies fabricate free-floating text. The two retrieval
strategies only ever surface claims that already exist (and were themselves
grounded when written). The two agentic strategies produce drafts that carry
their supporting members forward, so when a draft is committed it lands as a
claim wired to its evidence — the same claim + quote contract described in
Grounded extraction. Placing a claim in the wizard
never bypasses that contract; it only decides where a claim lives in the
outline.