Skip to content

Commit 48a9dc4

Browse files
feat(hero): split typographic hero (README) from architecture diagram (methodology)
Previous architecture.svg attempts were too information-dense for a hero slot — readers need a 1-2-second punch, not a 4-phase flow chart. So: - README hero is now hero.png: a typographic statement set in BigShoulders Bold ("ONE MODEL MISSES." / "THE OTHER TWO OFTEN CATCH.") with the violet "CATCH." as the single focal accent. No diagram, no info table — just the claim, rendered with care. - The 4-phase architecture diagram (architecture.png) moves to docs/methodology.md, where readers studying the method want density, not punch. - Old architecture.svg deleted (was the placeholder during iteration). Generated via canvas-design skill (PIL + canvas-fonts). Design philosophy ("Schematic Editorial") and PIL render scripts kept in assets/ for future regeneration.
1 parent 28e3d70 commit 48a9dc4

10 files changed

Lines changed: 551 additions & 82 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
1818
[中文 README](./README.zh-TW.md) · [Methodology](./docs/methodology.md) · [Course outline](#course-outline)
1919

20-
![architecture](./assets/architecture.svg)
20+
![multi-model-redteam — one model misses, the other two often catch](./assets/hero.png)
2121

2222
## Three ways to run it. None require an install of this repo.
2323

README.zh-TW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
1313
[English README](./README.md) · [方法論](./docs/methodology.zh-TW.md) · [章節索引](#章節索引)
1414

15-
![architecture](./assets/architecture.svg)
15+
![multi-model-redteam — 一家漏掉的,另外兩家通常會接住](./assets/hero.png)
1616

1717
## 三種跑法,都不用安裝這個 repo
1818

assets/_render_architecture.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
#!/usr/bin/env python3
2+
"""Render hero architecture diagram for multi-model-redteam README.
3+
4+
Design philosophy: Schematic Editorial — information graphic as
5+
editorial typographic composition. Restrained palette, hairline
6+
weights, asymmetric tension in header, symmetric flow in body.
7+
8+
Output: assets/architecture.png at 1760x1320 (≈1.33:1).
9+
"""
10+
import os
11+
from PIL import Image, ImageDraw, ImageFont
12+
13+
# =====================================================================
14+
# CONFIG
15+
# =====================================================================
16+
17+
W, H = 1760, 1360
18+
19+
# Warm paper-stock palette
20+
BG = (251, 249, 244)
21+
INK = (24, 22, 20)
22+
INK_SOFT = (74, 70, 64)
23+
INK_MUTE = (120, 116, 110)
24+
RULE = (208, 202, 192)
25+
DOT = (212, 205, 192)
26+
27+
# Per-model territories (low-saturation editorial palette)
28+
CLAUDE = dict(fill=(254, 240, 230), border=(172, 80, 48),
29+
ink=(110, 42, 16), accent=(200, 92, 56))
30+
CODEX = dict(fill=(231, 240, 233), border=(44, 92, 60),
31+
ink=(24, 60, 38), accent=(62, 122, 84))
32+
GEMINI = dict(fill=(227, 234, 245), border=(40, 70, 116),
33+
ink=(20, 46, 84), accent=(60, 96, 152))
34+
35+
VIOLET = dict(fill=(236, 228, 247), border=(88, 50, 144),
36+
ink=(54, 28, 96), solid=(108, 60, 172))
37+
38+
# =====================================================================
39+
# FONTS
40+
# =====================================================================
41+
42+
FONT_DIR = r'C:\Users\permoon\.claude\skills\canvas-design\canvas-fonts'
43+
def F(name, size):
44+
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
45+
46+
f_display = F('BigShoulders-Bold.ttf', 116)
47+
f_section_lbl = F('InstrumentSans-Bold.ttf', 18)
48+
f_section_num = F('GeistMono-Bold.ttf', 22)
49+
f_label = F('InstrumentSans-Bold.ttf', 28)
50+
f_body = F('InstrumentSans-Regular.ttf', 21)
51+
f_italic_lg = F('InstrumentSerif-Italic.ttf', 30)
52+
f_italic_sm = F('InstrumentSerif-Italic.ttf', 22)
53+
f_mono_micro = F('GeistMono-Regular.ttf', 14)
54+
f_mono_micro_b = F('GeistMono-Bold.ttf', 14)
55+
f_mono_small = F('GeistMono-Regular.ttf', 18)
56+
f_mono_med = F('GeistMono-Regular.ttf', 22)
57+
58+
# =====================================================================
59+
# CANVAS
60+
# =====================================================================
61+
62+
img = Image.new('RGB', (W, H), BG)
63+
d = ImageDraw.Draw(img)
64+
65+
# ---------- Subtle dot lattice (visual paper grain) ----------
66+
GRID = 28
67+
for y in range(96, H - 96, GRID):
68+
for x in range(120, W - 120, GRID):
69+
d.point((x, y), fill=DOT)
70+
d.point((x + 1, y), fill=DOT) # 2-px dot for visibility
71+
72+
# Helpers ----------------------------------------------------------
73+
def hairline(y, color=RULE, width=2):
74+
d.line((120, y, W - 120, y), fill=color, width=width)
75+
76+
def box(xy, fill, border, radius=12, width=2):
77+
d.rounded_rectangle(xy, radius=radius, fill=fill, outline=border, width=width)
78+
79+
def arrow_down(x, y, color, size=9):
80+
d.polygon([(x - size, y), (x + size, y), (x, y + size + 2)], fill=color)
81+
82+
def arrow_right(x, y, color, size=8):
83+
d.polygon([(x, y - size), (x, y + size), (x + size + 2, y)], fill=color)
84+
85+
def accent_swatch(x, y, color, w=14, h=14):
86+
"""Small rectangular color accent — replaces glyph diamond."""
87+
d.rectangle((x, y, x + w, y + h), fill=color)
88+
89+
# =====================================================================
90+
# HEADER STRIP (top)
91+
# =====================================================================
92+
hairline(96)
93+
d.text((120, 116), '§ A METHOD', font=f_mono_micro_b, fill=INK_SOFT)
94+
d.text((W - 120, 116), 'EDITION 01 — 2026', font=f_mono_micro_b, fill=INK_SOFT, anchor='rt')
95+
96+
# Display title — left-aligned, two-line stack
97+
title_x = 120
98+
title_y = 150
99+
d.text((title_x, title_y), 'MULTI – MODEL', font=f_display, fill=INK)
100+
d.text((title_x, title_y + 95), 'DESIGN RED TEAM', font=f_display, fill=INK)
101+
102+
# Editorial italic subtitle
103+
sub_y = title_y + 215
104+
d.text((title_x, sub_y), 'three LLMs review the same plan in parallel —',
105+
font=f_italic_lg, fill=INK_SOFT)
106+
d.text((title_x, sub_y + 36),
107+
'whatever one misses, the other two often catch.',
108+
font=f_italic_lg, fill=INK_SOFT)
109+
110+
# Right-side metadata column (asymmetric balance)
111+
meta_x = W - 120
112+
meta_y = title_y + 4
113+
d.text((meta_x, meta_y), 'METHOD', font=f_mono_micro_b, fill=INK_SOFT, anchor='rt')
114+
d.text((meta_x, meta_y + 22), '5-failure-dimension frame', font=f_mono_small, fill=INK, anchor='rt')
115+
116+
d.text((meta_x, meta_y + 70), 'OUTPUT', font=f_mono_micro_b, fill=INK_SOFT, anchor='rt')
117+
d.text((meta_x, meta_y + 92), 'severity-ranked findings', font=f_mono_small, fill=INK, anchor='rt')
118+
119+
d.text((meta_x, meta_y + 140), 'INSTALL', font=f_mono_micro_b, fill=INK_SOFT, anchor='rt')
120+
d.text((meta_x, meta_y + 162), 'paste ~30 lines into your CLAUDE.md', font=f_mono_small, fill=INK, anchor='rt')
121+
122+
# Hairline rule under header
123+
hr1_y = sub_y + 90
124+
hairline(hr1_y)
125+
126+
# =====================================================================
127+
# DIAGRAM
128+
# =====================================================================
129+
130+
# ----- Section 01 — INPUT -----
131+
sec_y = hr1_y + 36
132+
d.text((title_x, sec_y), '01', font=f_section_num, fill=INK)
133+
d.text((title_x + 44, sec_y + 4), 'INPUT', font=f_section_lbl, fill=INK)
134+
135+
plan_w, plan_h = 320, 68
136+
plan_cx = W // 2
137+
plan_x1 = plan_cx - plan_w // 2
138+
plan_y1 = sec_y + 28
139+
box((plan_x1, plan_y1, plan_x1 + plan_w, plan_y1 + plan_h),
140+
fill=BG, border=INK, radius=10, width=2)
141+
d.text((plan_cx, plan_y1 + 20), 'plan.md / spec / RFC',
142+
font=f_mono_med, fill=INK, anchor='mm')
143+
d.text((plan_cx, plan_y1 + 48), 'your design under review',
144+
font=f_italic_sm, fill=INK_SOFT, anchor='mm')
145+
146+
# Card geometry
147+
card_w, card_h = 380, 192
148+
card_gap = 80
149+
card_total = 3 * card_w + 2 * card_gap
150+
cards_y1 = sec_y + 168
151+
cards_x_start = (W - card_total) // 2
152+
claude_cx = cards_x_start + card_w // 2
153+
codex_cx = cards_x_start + card_w + card_gap + card_w // 2
154+
gemini_cx = cards_x_start + 2 * (card_w + card_gap) + card_w // 2
155+
156+
# Fan-out lines
157+
fan_top_y = plan_y1 + plan_h
158+
fan_bottom_y = cards_y1
159+
for tx in (claude_cx, codex_cx, gemini_cx):
160+
d.line((plan_cx, fan_top_y, tx, fan_bottom_y), fill=INK_MUTE, width=2)
161+
arrow_down(tx, fan_bottom_y - 4, INK_MUTE, size=8)
162+
163+
# Marginalia between fan-out
164+
margin_y = (fan_top_y + fan_bottom_y) // 2 - 12
165+
d.text((plan_cx, margin_y),
166+
'same prompt · in parallel · no cross-talk',
167+
font=f_italic_sm, fill=INK_MUTE, anchor='mm')
168+
169+
# ----- Section 02 — PARALLEL REVIEW -----
170+
sec2_y = cards_y1 - 26
171+
d.text((title_x, sec2_y), '02', font=f_section_num, fill=INK)
172+
d.text((title_x + 44, sec2_y + 4), 'PARALLEL REVIEW', font=f_section_lbl, fill=INK)
173+
174+
def model_card(x1, y1, palette, name, finds_lines):
175+
x2, y2 = x1 + card_w, y1 + card_h
176+
box((x1, y1, x2, y2), fill=palette['fill'],
177+
border=palette['border'], radius=14, width=2)
178+
179+
# Vertical bar accent — manuscript marginalia feel
180+
accent_swatch(x1 + 22, y1 + 18, palette['accent'], w=4, h=22)
181+
182+
# Name (bold sans)
183+
d.text((x1 + 38, y1 + 14), name, font=f_label, fill=palette['ink'])
184+
185+
# Sub-label
186+
d.text((x1 + 22, y1 + 64), '5-FRAME REVIEW',
187+
font=f_mono_micro_b, fill=palette['accent'])
188+
189+
# Inner rule under header
190+
d.line((x1 + 22, y1 + 88, x2 - 22, y1 + 88), fill=palette['border'], width=1)
191+
192+
# Finds list
193+
line_y = y1 + 104
194+
for line in finds_lines:
195+
d.text((x1 + 22, line_y), line, font=f_body, fill=palette['ink'])
196+
line_y += 28
197+
198+
model_card(cards_x_start, cards_y1, CLAUDE, 'CLAUDE CODE',
199+
['ordering, atomicity', 'rollback gaps', 'alert thresholds'])
200+
model_card(cards_x_start + card_w + card_gap, cards_y1, CODEX, 'CODEX CLI',
201+
['idempotency', 'schema drift', 'transport failures'])
202+
model_card(cards_x_start + 2 * (card_w + card_gap), cards_y1, GEMINI, 'GEMINI CLI',
203+
['timezone, partition', 'cross-region races', 'GCP-native traps'])
204+
205+
# ----- Converge lines into consolidate bar -----
206+
cards_y2 = cards_y1 + card_h
207+
conv_top_y = cards_y2 + 6
208+
consol_cx = W // 2
209+
conv_bottom_y = cards_y2 + 78
210+
for sx in (claude_cx, codex_cx, gemini_cx):
211+
d.line((sx, conv_top_y, consol_cx, conv_bottom_y), fill=INK_MUTE, width=2)
212+
arrow_down(consol_cx, conv_bottom_y - 4, INK_MUTE, size=8)
213+
214+
# ----- Section 03 — CONSOLIDATE -----
215+
sec3_y = cards_y2 + 44
216+
d.text((title_x, sec3_y), '03', font=f_section_num, fill=INK)
217+
d.text((title_x + 44, sec3_y + 4), 'CONSOLIDATE', font=f_section_lbl, fill=INK)
218+
219+
cons_w, cons_h = 540, 64
220+
cons_x1 = consol_cx - cons_w // 2
221+
cons_y1 = sec3_y + 30
222+
box((cons_x1, cons_y1, cons_x1 + cons_w, cons_y1 + cons_h),
223+
fill=INK, border=INK, radius=10, width=2)
224+
d.text((consol_cx, cons_y1 + 18), '4th LLM call', font=f_label, fill=BG, anchor='mm')
225+
d.text((consol_cx, cons_y1 + 46), 'merges findings by meaning, not by string match',
226+
font=f_italic_sm, fill=(195, 190, 180), anchor='mm')
227+
228+
# Arrow to findings
229+
out_arrow_top = cons_y1 + cons_h
230+
out_arrow_bot = out_arrow_top + 38
231+
d.line((consol_cx, out_arrow_top + 4, consol_cx, out_arrow_bot - 4), fill=INK_MUTE, width=2)
232+
arrow_down(consol_cx, out_arrow_bot - 4, INK_MUTE, size=8)
233+
234+
# ----- Section 04 — RANKED FINDINGS -----
235+
sec4_y = out_arrow_bot + 18
236+
d.text((title_x, sec4_y), '04', font=f_section_num, fill=INK)
237+
d.text((title_x + 44, sec4_y + 4), 'RANKED FINDINGS', font=f_section_lbl, fill=INK)
238+
239+
panel_w = 1120
240+
panel_h = 184
241+
panel_x1 = W // 2 - panel_w // 2
242+
panel_y1 = sec4_y + 28
243+
panel_x2 = panel_x1 + panel_w
244+
panel_y2 = panel_y1 + panel_h
245+
box((panel_x1, panel_y1, panel_x2, panel_y2), fill=BG, border=INK, radius=12, width=2)
246+
247+
# Header micro-row inside panel
248+
d.text((panel_x1 + 28, panel_y1 + 14), 'CATEGORY',
249+
font=f_mono_micro_b, fill=INK_MUTE)
250+
d.text((panel_x1 + 300, panel_y1 + 14), 'CRITERION',
251+
font=f_mono_micro_b, fill=INK_MUTE)
252+
d.text((panel_x1 + 680, panel_y1 + 14), 'INTERPRETATION',
253+
font=f_mono_micro_b, fill=INK_MUTE)
254+
d.line((panel_x1 + 28, panel_y1 + 38, panel_x2 - 28, panel_y1 + 38),
255+
fill=RULE, width=1)
256+
257+
# Row 1 — Consensus
258+
row1_y = panel_y1 + 50
259+
d.ellipse((panel_x1 + 32, row1_y + 8, panel_x1 + 48, row1_y + 24), fill=INK_SOFT)
260+
d.text((panel_x1 + 60, row1_y + 5), 'Consensus', font=f_label, fill=INK)
261+
d.text((panel_x1 + 300, row1_y + 9), '2+ teams agreed',
262+
font=f_body, fill=INK_SOFT)
263+
d.text((panel_x1 + 680, row1_y + 9),
264+
'strongest signal — almost certainly real',
265+
font=f_body, fill=INK_SOFT)
266+
267+
# Row 2 — Unique [HIGHLIGHT]
268+
row2_y = row1_y + 44
269+
hi_x1, hi_y1, hi_x2, hi_y2 = panel_x1 + 16, row2_y - 6, panel_x2 - 16, row2_y + 38
270+
box((hi_x1, hi_y1, hi_x2, hi_y2),
271+
fill=VIOLET['fill'], border=VIOLET['border'], radius=8, width=2)
272+
d.ellipse((panel_x1 + 32, row2_y + 8, panel_x1 + 48, row2_y + 24),
273+
fill=VIOLET['solid'])
274+
d.text((panel_x1 + 60, row2_y + 5), 'Unique', font=f_label, fill=VIOLET['ink'])
275+
d.text((panel_x1 + 300, row2_y + 9), '1 team only — others missed it',
276+
font=f_body, fill=VIOLET['ink'])
277+
d.text((panel_x1 + 680, row2_y + 9),
278+
'where single-model review would have failed',
279+
font=f_body, fill=VIOLET['ink'])
280+
281+
# Row 3 — Blind spot
282+
row3_y = row2_y + 50
283+
d.ellipse((panel_x1 + 32, row3_y + 8, panel_x1 + 48, row3_y + 24),
284+
outline=INK_SOFT, width=2)
285+
d.text((panel_x1 + 60, row3_y + 5), 'Blind spot', font=f_label, fill=INK)
286+
d.text((panel_x1 + 300, row3_y + 9), 'all 3 missed it',
287+
font=f_body, fill=INK_SOFT)
288+
d.text((panel_x1 + 680, row3_y + 9),
289+
'flagged by consolidator, conservatively',
290+
font=f_body, fill=INK_SOFT)
291+
292+
# Pointer to "Unique" with annotation
293+
ptr_x1 = panel_x2 + 16
294+
ptr_y = row2_y + 16
295+
ptr_x2 = ptr_x1 + 64
296+
d.line((ptr_x1, ptr_y, ptr_x2 - 6, ptr_y), fill=VIOLET['solid'], width=2)
297+
arrow_right(ptr_x2 - 6, ptr_y, VIOLET['solid'], size=6)
298+
d.text((ptr_x2 + 4, ptr_y - 22), 'value', font=f_mono_micro_b, fill=VIOLET['solid'])
299+
d.text((ptr_x2 + 4, ptr_y - 4), 'compounds', font=f_mono_micro_b, fill=VIOLET['solid'])
300+
d.text((ptr_x2 + 4, ptr_y + 14), 'here', font=f_mono_micro_b, fill=VIOLET['solid'])
301+
302+
# =====================================================================
303+
# FOOTER
304+
# =====================================================================
305+
hr2_y = panel_y2 + 44
306+
hairline(hr2_y)
307+
308+
# Bottom byline (italic display, left)
309+
d.text((120, hr2_y + 22),
310+
'no install · no plugin · no marketplace',
311+
font=f_italic_lg, fill=INK)
312+
313+
# Right-side mono note
314+
d.text((W - 120, hr2_y + 30),
315+
'paste ~30 lines into your CLAUDE.md → done',
316+
font=f_mono_small, fill=INK_SOFT, anchor='rt')
317+
318+
# =====================================================================
319+
# SAVE
320+
# =====================================================================
321+
out_path = r'D:\AI_Structure\multi-model-redteam\assets\architecture.png'
322+
img.save(out_path, optimize=True)
323+
print(f'Saved: {out_path}')
324+
print(f'Dimensions: {W}x{H}, size: {os.path.getsize(out_path)} bytes')

0 commit comments

Comments
 (0)