|
| 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