🧵

Dev Diary: Bridges Are Paths, Not Debates

Mar 20, 2026 14 min read

Two days ago I had 14,223 bridges, a 4-pass embedding-similarity system, 22 discrete vibe terms, axis-based contrast detection, and a frontend that looked like a nice SaaS dashboard. All technically working. None of it interesting. So I tore it all down and rebuilt it—the vibe system, the enrichment pipeline, the embeddings, the bridge algorithm, the narrative engine, and the design spec. Everything except the database itself.

Here’s what happened.


Killing the Vibe Terms

The 22 discrete vibes (“punk edge,” “romantic softness,” etc.) were always a problem—too many categories, too much overlap, and Claude kept assigning three or four to every single garment because honestly, a Victorian mourning dress is both “somber restraint” and “ornate elegance” depending on how you squint. The terms weren’t wrong, they just weren’t useful.

Replaced them with 6 axes, each a spectrum between two poles:

Axis Pole A Pole B
Volume Exaggerated Volume Column Minimalism
Ornament Maximalist Ornament Bare Surface
Exposure Body Display Body Concealment
Gender Gender Conforming Gender Defiant
Register Transgressive Subversion Elite Distinction
Occasion Pastoral Naturalism Ceremonial Formalism

Pick-a-pole plus confidence scoring, backfilled across all 4,234 products. And then I discovered something that changed the entire direction of the project: axes are great for filtering but terrible for defining bridges. The contrast framing—“these two garments disagree about volume”—produces results that are technically correct and completely boring. Bridges aren’t interesting as debates. They’re interesting as paths.


Enrichment from Scratch

If bridges need to be paths—shared designers, shared movements, construction techniques that span centuries, explicit influence citations—then I needed much richer metadata than what the old enrichment pipeline was producing. So I rewrote the Claude enrichment prompt completely, with a few principles that turned out to matter a lot:

The other win was caching: static template in the system message (cacheable by the API), per-item details in the user message. About 30% cost reduction, which matters when you’re re-enriching 4,234 products. Total cost for the full re-enrichment run: ~$50. Time: ~18 minutes at concurrency 10.


Bigger Embeddings

With richer text to embed, the old models weren’t cutting it. Upgraded both:

The build_rich_text function now includes display title, designer, influences, and movements—so the text embeddings actually capture the entity relationships that make bridges interesting. Had to ALTER TABLE the pgvector columns from their old dimensions to 768d, which sounds simple until your Supabase pooler times out mid-migration three times in a row (more on that later).


The Bridge Rewrite

This is the big one. After multiple iterations of the old 4-pass system—similarity, opposition, structural, visual echo—plus auditing results, generating narratives, and actually viewing them in the frontend, the conclusion was clear: bridges are only interesting as paths. Not “these two garments disagree about volume.” That’s trivia. What’s interesting is why two garments are connected—a shared designer, a shared movement, a construction technique that spans centuries, an explicit influence citation that draws a line from one era to another.

So I wrote better_bridges.py from scratch. Three passes, each finding a different kind of path:

Pass 1: Shared Entities

Build an inverted index mapping each entity value to its product set, then score every pair by IDF—log(N/count) times a type multiplier. The multipliers encode what I’ve learned about which connections are actually interesting:

Common entities like hand-sewing and tailoring get demoted (0.25× multiplier)—they connect everything to everything, which means they connect nothing to nothing. And there’s a blocklist for entities that are technically shared but carry zero narrative weight: “everyday-practical,” “status-signaling,” “geometric.” They still contribute to the score but don’t get displayed.

Quality gates: entity score ≥ 5.0 (8.0 for same-era pairs, because same-era is easy mode), at least one rare non-blocklisted entity with IDF ≥ 2.0, and a per-era cap of 300 bridges so no single era dominates.

Pass 2: Lineage

This is the pass I’m most excited about. Products with influence_references—where Claude identified that a garment explicitly references an earlier tradition—get matched against the corpus to find the source. Era parsing extracts decades (“1890s”), centuries, and era keywords from the influence strings, then a word index does fast matching with an embedding fallback for anything unresolved.

The key detail: lineage bridges are directed. Source is the older garment (the original tradition), target is the newer one (the referencer). This isn’t just “these two things are similar”—it’s “this thing came from that thing.” Each lineage match gets a +5.0 bonus because the reference itself is a high-value entity.

Results: ~1,020 lineage bridges, 460 via embedding fallback, and only 323 unmatched influence references (down from 2,597 before I added era parsing and the embedding fallback). That’s an 87% match rate.

Pass 3: Visual Echo

pgvector image similarity for pairs not already connected by the first two passes. This is the “surprises that metadata missed” pass—two garments that look strikingly similar but share no entities, no designer, no movement. Sometimes that’s convergent evolution in fashion, and sometimes it’s a connection that the enrichment pipeline couldn’t name yet. About 2,800 bridges from this pass, with batch commits every 500 to survive Supabase pooler timeouts.

Total: ~24,000 bridges. Every single one has a typed, weighted reason stored in shared_entities JSON. No more “these scored high on cosine similarity and I hope the narrative engine can figure out why.”

Scoring

bridge_score = sigmoid(entity_score + context_score + embedding_bonus)

entity_score:   IDF × type_multiplier per shared entity
context_score:  year_gap bonus (decade precision > era midpoint)
                + culture/category crossing bonus
embedding_bonus: small confirmation boost from text/image similarity

The schema got simpler too. StyleBridge gained shared_entities (JSON), entity_score, and directed. It lost eleven columns—structural_score, bridge_type, primary_axis, secondary_axis, contrast_pair, and six more artifacts of the opposition framing. Connection modes are now just three: shared_entity, lineage, visual_echo.


One Prompt to Narrate Them All

The old system had six mode-specific narrative prompts (one per bridge type). Now it’s one adaptive prompt that formats shared entities with human-readable labels, includes a distance line (“45 years apart • different cultures”), adds a lineage note when relevant (“Item B references ‘Japanese kimono draping’—Item A is that tradition”), and falls back to “visual form—see the images” for visual echoes with no entity overlap. Images go to Claude as vision content.

Quality gate at bridge_score ≥ 0.55 (0.45 for visual echo), per-product narrative cap of 5, and ordering that puts lineage first because those are almost always the most interesting. Here’s a lineage narrative the system generated:

“These tattered socks trace the arc of punk’s domestication—from Westwood and McLaren’s original anarchic unraveling to the movement’s gentler offspring where rebellion gets packaged into turquoise stripes and controlled fraying.”

That’s the system telling you why two things are connected, not just that they are. That’s the whole point.


Supabase War Stories (Brief)

The pooler timeout (~30s) will kill your connection during any CPU-bound work—embedding generation, bridge scoring, anything that takes a minute to process before committing. ResilientSession retries handle it but it’s noisy. The ALTER TABLE on 4,234 rows to change pgvector dimensions timed out three times before I gave up and temporarily upgraded compute. And the port situation: 6543 for pooler, not 5432 for direct—direct requires IPv6 or a paid IPv4 add-on. I should write a “things I wish I knew about Supabase” post at some point, but today is not that day.


The Design Pivot

Updated the design handoff to match the new system. Killed: axis sliders, opposition theater, “What Argues With This?”, vibe trails, all contrast/axis UI. Added: Thread Pull (the signature interaction—pull a thread from any garment and follow it through history), Bridge of the Day, Movement Trails, and an Influence Map. Browse modes are now Era, Culture, Movement, and Function instead of Vibe and Axis. Filter presets: Same Maker, Longest Echoes, Lineage, Visual Surprises, Cross-Culture.

And I wrote the full frontend refactor plan—6 phases, 15 days, 52 checkboxes. Phase 0 starts tomorrow: backend API schema updates, then rebuilding components with entity-based bridge data. Thread Pull is the wow feature. Target: deployed by April 10.


What This Means

The old system could tell you two garments were similar. The new system can tell you why—that they share a designer, or a movement, or that one explicitly references the tradition the other belongs to, or that they look strikingly similar despite having nothing else in common. Every bridge is now a story with a typed, weighted reason behind it. 24,000 threads through 400 years of fashion history, and every single one can explain itself.

Tomorrow I start building the frontend that lets you pull on those threads. I genuinely cannot wait.

Vintage Vestige: entity-based bridge discovery across 4,234 garments. Python, Claude API, pgvector, Supabase, sentence-transformers, CLIP.