DEV Community

Cover image for I spent 72 hours hunting the suppliers Apple's CFO has never heard of
Sarah Vogel
Sarah Vogel

Posted on

I spent 72 hours hunting the suppliers Apple's CFO has never heard of

Quick quiz. Name the three suppliers who decide whether iPhones ship next quarter.

TSMC. Foxconn. Samsung Display. That's what Apple's own 10-K lists. That's what any Tier-1 risk register would show you.

Now name a company called Carl Zeiss SMT. Or Cymer. Or a glass sand mine in Lower Saxony.

If you can't, that's the point. All three sit on Apple's critical path, four hops upstream. If Zeiss can't ship optics, ASML can't ship EUV scanners. If ASML can't ship, TSMC can't do 3nm. If TSMC can't do 3nm, Apple has a very bad quarter. But none of them are in anyone's register because registers stop at Tier-1.

I spent this weekend at the FalkorDB x wemakedevs hackathon trying to see how much of that blind spot you could illuminate in 72 hours with a graph DB and some agents. The thing I built is called Shockwave. The 90-second pitch is here if you prefer video.

The number that mattered
I built a validation set of 20 single points of failure that are documented in public filings (Apple-TSMC-ASML, Boeing-Spirit, Lockheed-P&W, that kind of thing). Then I ran Shockwave against it with no cheats and asked how many it would surface in the top 5 results.

Recall@5 17/20 (85%)
Hidden Recall@5 16/19 (84%) # of SPOFs that are 2+ hops away
The three I don't find — Lockheed to China rare-earth processing, Ford to SQM, Starbucks to Suez — are all missing edges in my seed data, not algorithm failures. I could patch them in and go 20/20 but then the number stops meaning anything. 17 feels right. I left it.

The whole thing is basically one Cypher query
I'm not going to pretend there's some deep architecture here. The entire product is one pattern:

MATCH p = (target:Entity {id: $target_id})-[:DEPENDS_ON*1..4]->(upstream:Entity)
WITH upstream, path,
reduce(score = 1.0, r IN relationships(p) |
score * r.criticality * (1.0 - r.substitutability)) AS path_impact,
length(p) AS hops
WHERE path_impact > 0.01
WITH upstream, min(hops) AS min_hops, sum(path_impact) AS agg_impact,
collect(DISTINCT [n IN nodes(p) | n.name])[0..3] AS sample_paths,
CASE WHEN min(hops) >= 2 THEN 1.5 ELSE 0.4 END AS depth_factor
RETURN upstream.name, min_hops, agg_impact,
agg_impact * depth_factor AS hidden_score, sample_paths
ORDER BY hidden_score DESC LIMIT 10
The reduce over relationships(p) is just the risk calculation. Multiplicative damping along a path: criticality × (1 - substitutability). Sole-source multiplied by sole-source multiplied by sole-source stays high. Add one commodity link and it collapses.

The line I actually care about is the one that looks dumb:

CASE WHEN min(hops) >= 2 THEN 1.5 ELSE 0.4
That's the whole pitch in two branches. Boost anything two or more hops away. Hammer the Tier-1 stuff down. So the output literally ranks the risks your register missed above the ones it already has.

FalkorDB handles that query in a couple of milliseconds against a 100-ish node graph. I didn't have to think about performance once, which is suspicious if you've worked with graph DBs before.

I wasted two hours on named graphs
My mental model going in was clean. One named graph for the world (companies, suppliers, resources). Another named graph for the agent's memory (questions, hypotheses, analyses). Separation of concerns, right?

FalkorDB doesn't do cross-graph Cypher. I didn't know that. I only found out when the Skeptic agent tried to pull "past hypotheses about Apple" and had to do it as two sequential queries with a Python join in between. Which is fine, but it meant the whole thing where a Cypher query could span the world AND the model's reasoning about the world (which is a kind of cool pitch) was dead.

Collapsed everything into one graph with :Entity, :Hypothesis, :Analysis, :NewsEvent labels. Did a global search-replace. Pushed. Done in like an hour but I was annoyed. If you're starting out on FalkorDB, just use one graph with labels. The named graphs thing is a trap for people who overthink.

The UI pivoted twice and that's embarrassing to admit
First version: dark theme, big chat input, "Try asking…" chips, pink accent. It looked like every Perplexity knockoff on Devpost right now. I showed it to no one. I didn't need to.

Second version: Bloomberg terminal density, hero stats row across the top, watchlist on the left, three agent panels down the middle. Better. Felt more like a tool. Still felt like software.

Third version is what shipped: a light cream palette, Instrument Serif for headlines, a drop cap on the lede, numbered sections. It reads like a long-form article in FT Weekend. No chat input visible. If you want to ask something you hit / and get a command palette.

I had to burn a lot of good code to get here. The lesson, if there is one: the moment your product is "a research output," stop designing software and start designing a magazine. The visual language of software (dense dashboards, chat panels, dark mode) actively works against you.

The agents, briefly
Three of them, in parallel, every scan.

Analyst does the real work. Calls find_entity to resolve the company. Then either simulate_shock (forward: what breaks if X dies) or find_hidden_spofs (reverse: what are my unknown risks). Writes the dossier body.

Skeptic attacks the Analyst. Has two tools: query_similar_hypotheses (did we claim something similar before and were we wrong), and find_counterexamples (are there companies with the same dependencies who are fine). This is the one that produced the best moment of the whole project. For Apple, it came back with:

"The assumption that Cymer and Zeiss are irreplaceable is weak. Comparable firms (Nvidia, AMD) share identical dependencies without catastrophic outages."

Which is a real counterexample pulled from the graph. Not role-play. It actually called the tool, got Nvidia and AMD back, and used them.

Historian pulls real past shocks (Foxconn Zhengzhou 2022, Cymer 2018, Suez 2021) from a small curated event dataset stored as :NewsEvent nodes in the same graph. It's the most honest agent — if there's no good precedent, it just says so. I was tempted to make it hallucinate when the dataset is thin. Didn't. Good decision.

One UX thing worth copying
The agent takes ~16 seconds for a full scan. Early version just showed a spinner for that whole time. Nobody waits 16 seconds on faith.

Fix: SSE stream from the backend emitting an event per tool call. Frontend renders this as a timeline that fills in live:

● READING YOUR QUESTION 0.4s
● ANALYST DECIDING WHERE TO LOOK 2.8s
● SEARCHING THE GRAPH 4.6s
found Apple (AAPL) · Company · 3 direct dependencies
● TRACING HIDDEN SINGLE POINTS OF FAILURE 10.6s
8 SPOFs, 6 hidden ≥2 hops · ASML, Carl Zeiss SMT, Cymer, TRUMPF, ...
● ANALYST COMPOSING THE DOSSIER 11.0s
Wall time is still 16s. Perceived wait is nothing because the user is reading "found Apple," "8 SPOFs, 6 hidden," real entity names, as they stream in. The engagement is in the specificity, not the speed.

Backend implementation is a worker thread calling on_event() into an asyncio.Queue via loop.call_soon_threadsafe. FastAPI StreamingResponse with media_type="text/event-stream" and the X-Accel-Buffering: no header or your proxy will eat the stream and you'll spend an hour debugging what's happening (I did, obviously).

The stack
FalkorDB Cloud free tier for the graph. Fast enough that I never thought about it.
Qwen3-235B via Nebius AI Studio for the agents. OpenAI-compatible endpoint, so you drop in the openai SDK and set a base URL.
FastAPI + SSE for the backend.
Vite + React + Tailwind + react-force-graph-2d for the frontend. Instrument Serif via Google Fonts.
Pytest for the ground truth evaluation, which I'm oddly proud of because it means the headline number isn't a vibe, it's a test that passes.
What I'd actually do next
Not more features. The product works. The data is what needs to grow. 80 hand-curated edges is enough to prove the thing works — not enough to be useful to a real risk team.

The next move is a slow, careful ingestion pipeline from SEC 10-Ks, probably LLM-extracted but with an aggressive precision filter. Something like "only trust extractions where the LLM quotes verbatim from the filing." Yield will be low. Quality will be high. That's the right tradeoff for a product whose entire value is naming the right company at the right hop depth.

Code: github.com/GodSmasher/shockwave. Live demo: shockwave-blindspots.vercel.app. Video: youtu.be/v6YKiRbhWf4.

Now I'm going to sleep.

Top comments (0)