<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Map-Reduce | Marcin Wylot, PhD</title>
    <link>https://mwylot.net/tags/map-reduce/</link>
      <atom:link href="https://mwylot.net/tags/map-reduce/index.xml" rel="self" type="application/rss+xml" />
    <description>Map-Reduce</description>
    <generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Thu, 09 Jul 2026 00:00:00 +0000</lastBuildDate>
    <image>
      <url>https://mwylot.net/media/logo_hu_e1fbd36c83cd87fc.png</url>
      <title>Map-Reduce</title>
      <link>https://mwylot.net/tags/map-reduce/</link>
    </image>
    
    <item>
      <title>Incremental Map-Reduce LLM Pipeline with Idempotent State and Prompt-Injection Defense</title>
      <link>https://mwylot.net/portfolio/incremental-mapreduce-insight-pipeline/</link>
      <pubDate>Thu, 09 Jul 2026 00:00:00 +0000</pubDate>
      <guid>https://mwylot.net/portfolio/incremental-mapreduce-insight-pipeline/</guid>
      <description>&lt;p&gt;For the same leadership-development AI platform I built the production infrastructure for (
), I designed and built the insight-synthesis layer — the pipeline that reads each user&amp;rsquo;s entire conversation history and distills it into a small set of durable, personalized insights that evolve as they keep using the product. The system is an incremental map-reduce over an LLM: a MAP stage extracts a structured signal per conversation, a REDUCE stage folds those signals into insights against a bounded, durable profile of the user&amp;rsquo;s history. Built on Temporal for reliability, it re-processes only what changed (keeping per-run LLM cost proportional to new activity rather than total history), treats every LLM output as untrusted input, and is idempotent under retries — no false success, no double-counted history, no silently dropped conversations.&lt;/p&gt;
&lt;h3 id=&#34;the-challenge&#34;&gt;The Challenge&lt;/h3&gt;
&lt;p&gt;The platform runs a Socratic AI companion whose value compounds over time — the more a user reflects, the more the product should understand their evolving practice. Turning that accumulated history into trustworthy, longitudinal insight raised a set of problems that single-shot LLM calls don&amp;rsquo;t have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Longitudinal, not per-session&lt;/strong&gt;: insights must reflect a user&amp;rsquo;s whole arc — dozens to hundreds of conversations — not one chat&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unbounded, ever-growing history&lt;/strong&gt;: a user&amp;rsquo;s transcript only grows, so naively re-reading everything on each run is cost-prohibitive at a per-user, minutes-level cadence&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost discipline&lt;/strong&gt;: only conversations that actually changed should ever hit the LLM again&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Correctness under a distributed retry engine&lt;/strong&gt;: a retried or duplicated run must not double-count history, advance state past unprocessed data, or persist a partial result as &amp;ldquo;done&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM output is untrusted&lt;/strong&gt;: extracted content is &lt;em&gt;persisted&lt;/em&gt; and fed back into later prompts — out-of-vocabulary or injection-poisoned output must never corrupt durable state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prompt injection&lt;/strong&gt;: conversation text is fully user-controlled and flows directly into extraction prompts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never truncate&lt;/strong&gt;: an arbitrarily long conversation must be fully processed, not clipped to fit a context window&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;They needed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Personalized, evolving insights synthesized from a user&amp;rsquo;s complete conversation history&lt;/li&gt;
&lt;li&gt;Cost that scales with &lt;em&gt;new&lt;/em&gt; activity, not with total history size&lt;/li&gt;
&lt;li&gt;Provable correctness under Temporal&amp;rsquo;s at-least-once activity execution&lt;/li&gt;
&lt;li&gt;A hardened trust boundary around every LLM call whose output touches persisted state&lt;/li&gt;
&lt;li&gt;Full, lossless processing of long conversations&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;solution-architecture&#34;&gt;Solution Architecture&lt;/h3&gt;
&lt;p&gt;I designed a two-stage map-reduce driven by a completion poller, with a durable per-user profile that lets REDUCE fold new activity forward instead of re-reading history:&lt;/p&gt;
&lt;div class=&#34;mermaid&#34;&gt;graph TD
    poller[5-min completion poller] --&gt; map[MAP: extract changed conversations]
    map --&gt;|conversations remain| drain[Drain: mark &#39;draining&#39;, re-fire next cycle]
    map --&gt;|coverage below threshold| retry[Skip REDUCE: re-MAP next cycle]
    map --&gt;|extract set complete| profile{Profile current?}
    profile --&gt;|dirty / schema bump / first run| bootstrap[Bootstrap: hierarchical fold over all history]
    profile --&gt;|current| foldfwd[Fold-forward: fold only aged-out extracts]
    bootstrap --&gt; hash{generation_hash changed?}
    foldfwd --&gt; hash
    hash --&gt;|no| noop[No-op: idempotent skip]
    hash --&gt;|yes| synth[Two-call synthesis: pattern + gated shift]
    synth --&gt; store[(Persisted insights)]
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;MAP — per-conversation structured extraction&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The MAP activity extracts one structured &lt;code&gt;ConversationExtract&lt;/code&gt; per conversation that needs (re)processing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Change detection&lt;/strong&gt;: a per-conversation &lt;code&gt;source_fingerprint&lt;/code&gt; is compared against the persisted extract, so only genuinely changed conversations are re-mapped — every unchanged conversation is skipped before any token is spent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bounded, isolated parallelism&lt;/strong&gt;: candidate conversations are mapped concurrently under an &lt;code&gt;asyncio.Semaphore&lt;/code&gt;, with Temporal heartbeats as they complete; a failure on one conversation is isolated and never fails the user&amp;rsquo;s run&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No-truncation windowing&lt;/strong&gt;: oversized conversations are split into token-budgeted windows, each extracted separately and then merged — content is never clipped, and the merge deliberately preserves the &lt;em&gt;earliest&lt;/em&gt; framing markers as frozen &amp;ldquo;then&amp;rdquo; anchors&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model-facing schema with code-owned provenance&lt;/strong&gt;: extraction is enforced as a Pydantic model via tool-calling structured output; factual provenance the model can&amp;rsquo;t know (conversation UUID, timestamps) is injected by code, not produced by the model&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cold-start draining&lt;/strong&gt;: when more conversations remain than one batch should map, the user&amp;rsquo;s row is set to &lt;code&gt;draining&lt;/code&gt; and the workflow returns &lt;code&gt;in_progress&lt;/code&gt; so the poller re-fires next cycle — bounded batches over poll cycles instead of an unbounded &lt;code&gt;continue_as_new&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail-closed coverage gate&lt;/strong&gt;: if the per-batch error ratio exceeds a configured threshold, REDUCE is skipped entirely and the failed conversations re-map next cycle — the system never synthesizes insights from partial coverage&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;REDUCE — folding extracts into insights&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The REDUCE activity aggregates a user&amp;rsquo;s extracts into the insight blob, backed by a durable &lt;code&gt;PracticeProfile&lt;/code&gt; that keeps the token budget bounded no matter how long the history grows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bootstrap vs. fold-forward&lt;/strong&gt;: on first run, a schema bump, or an invalidated profile, a chronological hierarchical reduce folds &lt;em&gt;all&lt;/em&gt; history into a compacted profile (running outline + principle/dimension tallies + frozen earliest anchors). On every subsequent run it &lt;em&gt;folds forward&lt;/em&gt; — advancing a &lt;code&gt;covered_through&lt;/code&gt; watermark and folding only the extracts that have aged out of the granular recent window&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recent-at-full-granularity, history-compacted&lt;/strong&gt;: the most recent extracts are read verbatim (they drive the current pattern); everything older lives in the bounded profile outline. This is what caps cost as history grows without number&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Idempotency, three ways&lt;/strong&gt;: a SHA-256 &lt;code&gt;generation_hash&lt;/code&gt; over the inputs short-circuits any run whose inputs are unchanged; the fold happens &lt;em&gt;before&lt;/em&gt; the watermark is mutated, so a failure can never advance &lt;code&gt;covered_through&lt;/code&gt; past an unsummarized batch; and the insight write plus its side effects commit in a single caller-owned transaction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two-call gated synthesis&lt;/strong&gt;: a pattern unit (through-line + unexplored-principle) and a gated &amp;ldquo;what&amp;rsquo;s shifted&amp;rdquo; unit run concurrently with per-call failure isolation; the expensive shift analysis is gated behind minimum-history and minimum-timespan thresholds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sharpen, don&amp;rsquo;t whiplash&lt;/strong&gt;: prior observations are fed back into the synthesis prompt so insights evolve incrementally rather than lurching between runs, with section-level freshness resolving fresh output against the previous result&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;ai-security-and-trust-boundaries&#34;&gt;AI Security and Trust Boundaries&lt;/h3&gt;
&lt;p&gt;Because extracted output is persisted and re-fed into later prompts, I treated the LLM as an untrusted component and hardened the boundary on both sides of every call:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;User content is DATA, not instructions&lt;/strong&gt;: transcript text has its angle brackets escaped before entering the prompt, so a message can&amp;rsquo;t close the &lt;code&gt;&amp;lt;transcript&amp;gt;&lt;/code&gt; wrapper or inject markup the model would treat as instructions — the transcript stays human-readable while being inert&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM output is allow-listed before persistence&lt;/strong&gt;: every principle and behavioral dimension the model emits is checked against the controlled catalog/enum and dropped if it isn&amp;rsquo;t a member, so a prompt-injection attempt can&amp;rsquo;t poison durable state with fabricated vocabulary&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Provenance is code-owned&lt;/strong&gt;: conversation identity and timestamps are injected by code and never taken from model output, so the model cannot misattribute an extract to another conversation or user&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail-closed everywhere&lt;/strong&gt;: partial MAP coverage, a failed profile fold, or a failed core synthesis all refuse to persist a &amp;ldquo;success&amp;rdquo; — the run is marked for retry with prior state left intact, rather than shipping a confidently-wrong result&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;technical-implementation&#34;&gt;Technical Implementation&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Temporal orchestration&lt;/strong&gt;: MAP and REDUCE are activities with explicit start-to-close (10 min) and heartbeat (5 min) timeouts and bounded retry policies; the workflow is triggered per-user by a completion poller, and REDUCE re-raises retryable failures so Temporal — not a swallowed exception — owns the retry&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured output via tool-calling&lt;/strong&gt;: every LLM interaction (extraction, profile fold, pattern synthesis, shift synthesis) is a schema-validated tool call into a Pydantic model, eliminating free-text parsing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Langfuse observability&lt;/strong&gt;: one trace per conversation in MAP (session-scoped to the conversation, each window a separate generation) and one per-user trace for the whole REDUCE, giving prompt/latency/cost visibility at both granularities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pure-function core&lt;/strong&gt;: partitioning, window/epoch merging, tally accumulation, and the generation-hash preimage are all pure functions, unit-tested independently of the database and the LLM — the deterministic logic is provable without touching a model&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Config-driven budgets&lt;/strong&gt;: token budgets, concurrency limits, batch sizes, the coverage error-ratio threshold, and the shift-gating thresholds are all externalized settings rather than hard-coded constants&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;key-design-decisions&#34;&gt;Key Design Decisions&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Incremental fold-forward over full re-synthesis:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Re-reading a user&amp;rsquo;s entire history on every run is simple but scales cost with total history — untenable at a minutes-level cadence&lt;/li&gt;
&lt;li&gt;The durable profile + watermark makes per-run cost proportional to &lt;em&gt;new&lt;/em&gt; conversations, so a long-tenured user costs no more per run than a new one&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Watermark advanced only after a successful fold:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advancing &lt;code&gt;covered_through&lt;/code&gt; before persisting the fold would let a mid-run failure silently drop the unsummarized batch from history forever&lt;/li&gt;
&lt;li&gt;Folding first and mutating second makes the whole REDUCE safely retryable — a post-commit retry sees the advanced watermark and correctly folds nothing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Treat LLM output as untrusted input:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Most pipelines validate the &lt;em&gt;shape&lt;/em&gt; of structured output; here the &lt;em&gt;contents&lt;/em&gt; are allow-listed against a controlled vocabulary before they ever reach durable state&lt;/li&gt;
&lt;li&gt;Combined with transcript escaping and code-owned provenance, this closes the prompt-injection path into persisted, re-prompted data&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Cold-start drain over &lt;code&gt;continue_as_new&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bounded per-cycle batches that lean on the existing poller are simpler to reason about and observe than a self-continuing workflow, with no loss of eventual completeness&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Fail-closed synthesis:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A coverage gate and retryable-error contract guarantee the system would rather produce &lt;em&gt;nothing&lt;/em&gt; this cycle than a plausible insight synthesized from partial or corrupted inputs&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;results--impact&#34;&gt;Results &amp;amp; Impact&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Delivered a longitudinal insight-synthesis layer that distills a user&amp;rsquo;s full conversation history into three evolving, personalized insights (a through-line pattern, an unexplored principle, and how their framing has shifted over time)&lt;/li&gt;
&lt;li&gt;Architected an incremental map-reduce that keeps per-run LLM cost proportional to changed conversations rather than total history, via per-conversation fingerprint change-detection and a durable fold-forward profile&lt;/li&gt;
&lt;li&gt;Made the pipeline idempotent under Temporal&amp;rsquo;s at-least-once execution — generation-hash short-circuiting, fold-before-mutate watermarking, and single-transaction side effects eliminate false success and double-counting&lt;/li&gt;
&lt;li&gt;Hardened the LLM trust boundary end-to-end: prompt-injection escaping of user content, controlled-vocabulary allow-listing of persisted model output, and code-owned provenance&lt;/li&gt;
&lt;li&gt;Guaranteed lossless processing of arbitrarily long conversations through token-budgeted windowing that splits, extracts, and merges without truncation&lt;/li&gt;
&lt;li&gt;Instrumented full Langfuse observability at both conversation and user granularity for prompt, latency, and cost tracing&lt;/li&gt;
&lt;li&gt;Isolated all deterministic logic into a pure-function core with dedicated unit tests, keeping correctness verifiable independently of the LLM&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;technologies&#34;&gt;Technologies&lt;/h3&gt;
&lt;p&gt;Python, Temporal, Structured Output (tool-calling), Pydantic, asyncio, Langfuse,
PostgreSQL, SQLAlchemy, SHA-256 idempotency, Prompt-Injection Hardening&lt;/p&gt;</description>
    </item>
    
  </channel>
</rss>
