Use this Skill to extract and visualize argument structure: Toulmin reconstruction, support/attack graph, circularity detection, argument scheme classification, and NLP-based premise extraction.
TL;DR — Reconstruct arguments using the Toulmin model (claim, data, warrant, backing, rebuttal, qualifier), build directed support/attack graphs with NetworkX, detect circular reasoning and inconsistency, classify argument schemes, and visualize argument structure with colour-coded edges.
Use this Skill when you need to:
Do not use this Skill for:
Argument mapping makes reasoning visible and evaluable. The Toulmin model (1958) breaks every argument into six functional components:
| Component | Role | Example |
|---|---|---|
| Claim (C) | The conclusion being argued for | "We should ban single-use plastics." |
| Data (D) | Grounds / evidence supporting the claim | "Marine plastics kill 1M seabirds/year." |
| Warrant (W) | The inferential bridge from D to C | "Harms to wildlife justify bans." |
| Backing (B) | Support for the warrant itself | "Environmental ethics require harm reduction." |
| Rebuttal (R) | Exceptions or counter-considerations | "Unless economic disruption is too great." |
| Qualifier (Q) | Hedging the strength of the claim | "Presumably", "in most cases" |
An argument graph models propositions as nodes and support/attack relationships as edges. Formal properties:
# Create Python environment
conda create -n arg-map python=3.11 -y
conda activate arg-map
# Install dependencies
pip install "networkx>=3.1" "pandas>=1.5" "matplotlib>=3.6" "nltk>=3.8"
# Download NLTK data for tokenization and similarity
python -c "
import nltk
nltk.download('punkt')
nltk.download('stopwords')
nltk.download('wordnet')
"
# Verify
python -c "import networkx as nx; print(nx.__version__)"
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from typing import Optional
SUPPORT_COLOR = "#2ca02c" # green
ATTACK_COLOR = "#d62728" # red
NODE_COLOR = "#aec7e8" # light blue
def build_argument_graph(
propositions: list[dict],
relations: list[dict],
) -> nx.DiGraph:
"""
Build a directed argument graph from proposition and relation lists.
Args:
propositions: List of dicts with keys:
id (str), text (str), type (str: claim/premise/rebuttal).
relations: List of dicts with keys:
source (str), target (str), relation_type (str: support/attack).
Returns:
Directed graph with node attribute 'text' and 'node_type',
edge attribute 'relation_type'.
"""
G = nx.DiGraph()
for prop in propositions:
G.add_node(
prop["id"],
text=prop.get("text", ""),
node_type=prop.get("type", "premise"),
)
for rel in relations:
G.add_edge(
rel["source"],
rel["target"],
relation_type=rel.get("relation_type", "support"),
)
return G
def detect_circularity(G: nx.DiGraph) -> list[list]:
"""
Detect circular reasoning in the argument graph.
Restricts the search to support edges only (attack edges are legitimate
counter-arguments, not circular reasoning per se).
Args:
G: Directed argument graph from build_argument_graph().
Returns:
List of cycles (each cycle is a list of node IDs).
Empty list means no circularity detected.
"""
support_subgraph = nx.DiGraph([
(u, v) for u, v, d in G.edges(data=True)
if d.get("relation_type") == "support"
])
support_subgraph.add_nodes_from(G.nodes)
cycles = list(nx.simple_cycles(support_subgraph))
return cycles
def check_inconsistency(
G: nx.DiGraph,
negation_map: dict,
) -> list[tuple]:
"""
Check for inconsistency: both a proposition and its negation appear
as reachable premises supporting the same claim.
Args:
G: Directed argument graph.
negation_map: Dict mapping proposition ID to the ID of its negation.
E.g. {"P1": "P1_neg"} means P1 and P1_neg are contradictory.
Returns:
List of (claim_node, prop_id, negation_id) triples where inconsistency found.
"""
inconsistencies = []
for claim_node in G.nodes:
if G.nodes[claim_node].get("node_type") != "claim":
continue
# Find all nodes reachable via support edges from premises to this claim
ancestors = nx.ancestors(G, claim_node)
ancestors.add(claim_node)
for prop_id, neg_id in negation_map.items():
if prop_id in ancestors and neg_id in ancestors:
inconsistencies.append((claim_node, prop_id, neg_id))
return inconsistencies
def visualize_argument_graph(
G: nx.DiGraph,
title: str = "Argument Map",
output_path: str = None,
show_labels: bool = True,
) -> None:
"""
Visualize an argument graph with green support edges and red attack edges.
Node colour encodes type: claim (gold), premise (light blue), rebuttal (salmon).
Args:
G: Directed argument graph.
title: Plot title.
output_path: If given, save figure here.
show_labels: Whether to display node text labels.
"""
type_colors = {
"claim": "#FFD700", # gold
"premise": "#AEC7E8", # light blue
"rebuttal": "#FFBB78", # salmon
"backing": "#C5B0D5", # lavender
}
node_colors = [
type_colors.get(G.nodes[n].get("node_type", "premise"), "#AEC7E8")
for n in G.nodes
]
edge_colors = [
SUPPORT_COLOR if d.get("relation_type") == "support" else ATTACK_COLOR
for _, _, d in G.edges(data=True)
]
pos = nx.spring_layout(G, seed=42, k=2.0)
fig, ax = plt.subplots(figsize=(12, 8))
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1800, ax=ax)
nx.draw_networkx_edges(
G, pos, edge_color=edge_colors, arrows=True,
arrowsize=20, width=2.0, ax=ax,
connectionstyle="arc3,rad=0.1",
)
if show_labels:
labels = {}
for n in G.nodes:
text = G.nodes[n].get("text", n)
# Wrap long labels
words = text.split()
wrapped = "\n".join(
" ".join(words[i:i+4]) for i in range(0, len(words), 4)
)
labels[n] = f"[{n}]\n{wrapped[:60]}"
nx.draw_networkx_labels(G, pos, labels, font_size=7, ax=ax)
# Legend
legend_items = [
mpatches.Patch(color=SUPPORT_COLOR, label="Support"),
mpatches.Patch(color=ATTACK_COLOR, label="Attack"),
mpatches.Patch(color="#FFD700", label="Claim"),
mpatches.Patch(color="#AEC7E8", label="Premise"),
mpatches.Patch(color="#FFBB78", label="Rebuttal"),
]
ax.legend(handles=legend_items, loc="upper left", fontsize=8)
ax.set_title(title, fontsize=13, fontweight="bold")
ax.axis("off")
fig.tight_layout()
if output_path:
fig.savefig(output_path, dpi=150, bbox_inches="tight")
print(f"Argument map saved to {output_path}")
plt.show()
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ToulminArgument:
"""
Data class representing a fully articulated Toulmin argument structure.
Attributes:
claim: The main conclusion (what is being argued).
data: The grounds/evidence (why the claim is made).
warrant: The inferential rule connecting data to claim.
backing: Support for the warrant.
rebuttal: Exceptions or counter-considerations.
qualifier: Hedging phrase (e.g. "presumably", "certainly", "in most cases").
"""
claim: str
data: str
warrant: str
backing: str = ""
rebuttal: str = ""
qualifier: str = "presumably"
def to_argument_graph(self) -> nx.DiGraph:
"""Convert the Toulmin structure to a NetworkX argument graph."""
propositions = [
{"id": "C", "text": self.claim, "type": "claim"},
{"id": "D", "text": self.data, "type": "premise"},
{"id": "W", "text": self.warrant, "type": "premise"},
]
relations = [
{"source": "D", "target": "C", "relation_type": "support"},
{"source": "W", "target": "C", "relation_type": "support"},
]
if self.backing:
propositions.append({"id": "B", "text": self.backing, "type": "backing"})
relations.append({"source": "B", "target": "W", "relation_type": "support"})
if self.rebuttal:
propositions.append({"id": "R", "text": self.rebuttal, "type": "rebuttal"})
relations.append({"source": "R", "target": "C", "relation_type": "attack"})
return build_argument_graph(propositions, relations)
def summarize(self) -> str:
"""Return a formatted plain-text Toulmin summary."""
lines = [
f"CLAIM : {self.qualifier.upper()}, {self.claim}",
f"DATA : {self.data}",
f"WARRANT : {self.warrant}",
]
if self.backing:
lines.append(f"BACKING : {self.backing}")
if self.rebuttal:
lines.append(f"REBUTTAL : Unless {self.rebuttal}")
return "\n".join(lines)
def classify_argument_scheme(
argument_text: str,
keywords: dict = None,
) -> str:
"""
Classify an argument into one of Walton's standard argument schemes.
Uses keyword heuristics to identify the dominant scheme. For production
use, replace with a fine-tuned classifier.
Schemes: authority, analogy, cause, sign, example, slippery-slope, ad-hominem.
Args:
argument_text: The raw argument text string.
keywords: Optional dict {scheme: [keywords]}. Uses built-in defaults if None.
Returns:
The most likely scheme name as a string.
"""
if keywords is None:
keywords = {
"authority": ["expert", "authority", "professor", "studies show",
"according to", "research", "scientist"],
"analogy": ["similarly", "just as", "like", "analogous", "comparable",
"in the same way", "resembles"],
"cause": ["causes", "because", "therefore", "leads to", "results in",
"due to", "effect", "consequence"],
"sign": ["indicates", "suggests", "is a sign", "symptom", "evidence",
"points to", "signal"],
"example": ["for example", "for instance", "such as", "e.g.", "case",
"illustrates", "consider"],
"slippery-slope": ["will lead to", "eventually", "first step", "next thing",
"inevitably", "chain of events"],
"ad-hominem": ["bias", "interest", "corrupt", "liar", "untrustworthy",
"agenda", "motivated"],
}
text_lower = argument_text.lower()
scheme_scores = {
scheme: sum(1 for kw in kws if kw in text_lower)
for scheme, kws in keywords.items()
}
best_scheme = max(scheme_scores, key=scheme_scores.get)
if scheme_scores[best_scheme] == 0:
return "unclassified"
return best_scheme
import nltk
from nltk.tokenize import sent_tokenize
from nltk.corpus import stopwords
import string
def extract_propositions(text: str) -> list[str]:
"""
Segment a passage into candidate propositions (declarative sentences).
Args:
text: Input argument text.
Returns:
List of candidate proposition strings.
"""
sentences = sent_tokenize(text)
# Filter out non-declarative (questions, exclamations)
declarative = [
s.strip() for s in sentences
if s.strip() and not s.strip().endswith("?") and not s.strip().endswith("!")
and len(s.split()) >= 5
]
return declarative
def sentence_similarity_jaccard(sent_a: str, sent_b: str) -> float:
"""
Compute Jaccard similarity between two sentences as bags of content words.
Args:
sent_a: First sentence string.
sent_b: Second sentence string.
Returns:
Jaccard similarity coefficient (0.0 to 1.0).
"""
stop = set(stopwords.words("english")) | set(string.punctuation)
def tokenize(s):
return {
w.lower() for w in nltk.word_tokenize(s)
if w.lower() not in stop and w.isalpha()
}
a = tokenize(sent_a)
b = tokenize(sent_b)
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def infer_support_attack_relations(
propositions: list[str],
support_cues: list[str] = None,
attack_cues: list[str] = None,
similarity_threshold: float = 0.15,
) -> list[dict]:
"""
Infer support and attack relations between propositions using cue phrases
and Jaccard content-word similarity.
This is a heuristic approximation; manual review is always recommended
for scholarly argument maps.
Args:
propositions: List of sentence strings.
support_cues: Discourse markers indicating support.
attack_cues: Discourse markers indicating attack.
similarity_threshold: Minimum Jaccard to propose a relation.
Returns:
List of relation dicts: source_idx, target_idx, relation_type, confidence.
"""
if support_cues is None:
support_cues = [
"therefore", "thus", "hence", "because", "since",
"this shows", "this means", "as a result", "consequently",
]
if attack_cues is None:
attack_cues = [
"however", "but", "although", "nevertheless", "on the other hand",
"despite", "yet", "in contrast", "this ignores",
]
relations = []
for i, sent_i in enumerate(propositions):
sent_lower = sent_i.lower()
for j, sent_j in enumerate(propositions):
if i == j:
continue
jacc = sentence_similarity_jaccard(sent_i, sent_j)
if jacc < similarity_threshold:
continue
# Classify by cue phrases
has_support = any(cue in sent_lower for cue in support_cues)
has_attack = any(cue in sent_lower for cue in attack_cues)
if has_support and not has_attack:
rel_type = "support"
elif has_attack and not has_support:
rel_type = "attack"
else:
# Default to support for thematically similar consecutive sentences
if abs(i - j) == 1:
rel_type = "support"
else:
continue
relations.append({
"source_idx": i,
"target_idx": j,
"relation_type": rel_type,
"confidence": round(jacc, 3),
})
return relations
def evaluate_argument_strength(
G: nx.DiGraph,
claim_node: str,
) -> dict:
"""
Heuristically evaluate the strength of an argument network.
Metrics:
- support_count: Number of direct support edges to the claim
- attack_count: Number of direct attack edges to the claim
- depth: Maximum reasoning chain depth (longest support path to claim)
- has_backing: Whether any warrant has a backing node
Args:
G: Directed argument graph.
claim_node: Node ID of the main claim.
Returns:
Dict with support_count, attack_count, depth, has_backing, verdict.
"""
support_in = sum(
1 for _, _, d in G.in_edges(claim_node, data=True)
if d.get("relation_type") == "support"
)
attack_in = sum(
1 for _, _, d in G.in_edges(claim_node, data=True)
if d.get("relation_type") == "attack"
)
# Depth = longest path to claim from any leaf node via support edges
support_G = nx.DiGraph([
(u, v) for u, v, d in G.edges(data=True)
if d.get("relation_type") == "support"
])
support_G.add_nodes_from(G.nodes)
try:
paths = nx.single_target_shortest_path_length(support_G, claim_node)
depth = max(paths.values()) if paths else 0
except Exception:
depth = 0
has_backing = any(
G.nodes[n].get("node_type") == "backing" for n in G.nodes
)
verdict = "strong" if support_in >= 2 and attack_in == 0 else \
"contested" if attack_in > 0 else \
"weak" if support_in <= 1 else "moderate"
return {
"support_count": support_in,
"attack_count": attack_in,
"depth": depth,
"has_backing": has_backing,
"verdict": verdict,
}
| Problem | Cause | Fix |
|---|---|---|
LookupError: punkt on sentence tokenization | NLTK data not downloaded | Run nltk.download('punkt_tab') (newer NLTK) |
nx.simple_cycles() hangs on large graph | Dense graph with many cycles | Limit to support sub-graph; set nx.simple_cycles(G, length_bound=5) |
| All relations classified as "support" | Lack of cue phrases in text | Add domain-specific cue words to attack_cues list |
| Spring layout overlaps many nodes | Dense graph | Try nx.kamada_kawai_layout() or nx.shell_layout() |
| Argument scheme always "unclassified" | Academic/formal text without colloquial cues | Extend keyword dict with domain vocabulary |
| Circular detection finds false cycles | Attack edges form apparent cycles | Filter to support-only subgraph before cycle detection |
# Reconstruct and visualize a Toulmin argument about plastic bans
arg = ToulminArgument(
claim="Single-use plastics should be banned.",
data="Marine plastic pollution kills over one million seabirds annually.",
warrant="Practices that cause large-scale wildlife death ought to be prohibited.",
backing="Environmental ethics requires minimizing unnecessary harm to other species.",
rebuttal="the economic disruption to packaging industries is prohibitive",
qualifier="presumably",
)
print(arg.summarize())
G = arg.to_argument_graph()
cycles = detect_circularity(G)
print(f"\nCircular reasoning detected: {len(cycles) > 0}")
print(f"Cycles: {cycles}")
strength = evaluate_argument_strength(G, claim_node="C")
print(f"\nArgument strength verdict: {strength['verdict']}")
print(f" Support edges: {strength['support_count']}, Attack edges: {strength['attack_count']}")
visualize_argument_graph(
G,
title="Toulmin Map: Plastic Ban Argument",
output_path="/data/output/plastic_ban_argument.png",
)
policy_text = """
The government should invest in renewable energy because fossil fuels are depleting rapidly.
Since climate change threatens global stability, transitioning to renewables is essential.
However, the upfront costs of renewable infrastructure are extremely high.
Expert consensus from the IPCC confirms that renewable transition is technically feasible.
Therefore, a managed transition over twenty years represents the most viable approach.
"""
# Extract propositions and classify scheme
propositions = extract_propositions(policy_text)
print(f"Extracted {len(propositions)} propositions:")
for i, p in enumerate(propositions):
print(f" [{i}] {p}")
scheme = classify_argument_scheme(policy_text)
print(f"\nPrimary argument scheme: {scheme}")
# Infer relations between propositions
relations = infer_support_attack_relations(propositions, similarity_threshold=0.1)
print(f"\nInferred {len(relations)} relations:")
for r in relations:
print(f" [{r['source_idx']}] --{r['relation_type']}--> [{r['target_idx']}] (conf={r['confidence']})")
# Build and visualize graph
prop_dicts = [{"id": str(i), "text": p, "type": "premise"} for i, p in enumerate(propositions)]
if prop_dicts:
prop_dicts[0]["type"] = "claim"
rel_dicts = [
{"source": str(r["source_idx"]), "target": str(r["target_idx"]),
"relation_type": r["relation_type"]}
for r in relations
]
G2 = build_argument_graph(prop_dicts, rel_dicts)
cycles2 = detect_circularity(G2)
print(f"\nCircular reasoning detected: {len(cycles2) > 0}")
visualize_argument_graph(G2, title="Policy Argument Map", output_path="/data/output/policy_argument.png")
| Version | Date | Change |
|---|---|---|
| 1.0.0 | 2026-03-18 | Initial release — Toulmin data class, argument graph, circularity detection, scheme classification, NLP relation extraction |