<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>NineLives blog: replay</title>
  <subtitle>NineLives blog posts about replay.</subtitle>
  <link rel="self" type="application/atom+xml" href="https://ninelives.getfelix.dev/tags/replay/feed.xml"/>
  <link rel="alternate" type="text/html" href="https://ninelives.getfelix.dev/blog/"/>
  <id>https://ninelives.getfelix.dev/tags/replay/</id>
  <updated>2026-10-31T00:00:00Z</updated>
  <entry>
    <title>What if every machine decision could be replayed?</title>
    <link rel="alternate" type="text/html" href="https://ninelives.getfelix.dev/blog/what-if-every-decision-could-be-replayed/"/>
    <id>https://ninelives.getfelix.dev/blog/what-if-every-decision-could-be-replayed/</id>
    <published>2026-10-31T00:00:00Z</published>
    <updated>2026-10-31T00:00:00Z</updated>
    <author><name>Gabriel Loewen</name></author>
    <summary>An idea being explored, not something built: treat a model call as an effect, record its answer as an input, and replay the decision without asking the model again.</summary>
    <content type="html">&lt;p&gt;A gateway on a pipeline closes a valve at 02:14. A camera on a line flags a good part as bad. A depot reroutes a van around a road that was open. In the morning someone asks the same four questions: what did the machine see, which model was running, what did the model say, and would it do the same again?&lt;/p&gt;
&lt;p&gt;Usually the answer is a log line someone chose to write, a model that has since been redeployed, and a guess. This post is about how the answer could be a set of records instead. It&#39;s an idea for NineLives that I&#39;m exploring, not something that exists. The &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GetFelix/ninelives/blob/main/docs/inference-as-effect.md&quot;&gt;design&lt;/a&gt; has the details, and none of it is built.&lt;/p&gt;
&lt;h2 id=&quot;running-the-model-again-isn-t-a-replay&quot;&gt;Running the model again isn&#39;t a replay&lt;/h2&gt;
&lt;p&gt;The obvious way to replay a decision is to feed the same input to the same model. That doesn&#39;t work, because inference is rarely reproducible bit for bit. A GPU adds numbers in whatever order its threads finish, and floating point addition isn&#39;t associative. Engines pick kernels by hardware, driver version and batch size, so a request batched with others can come out different from the same request alone. Even on a CPU, SIMD width and thread count change the order of additions, and ONNX Runtime and llama.cpp both give slightly different numbers across machines and settings. A language model sampling above zero temperature draws random numbers. And a fleet never upgrades its GPUs and drivers all at once.&lt;/p&gt;
&lt;p&gt;Most of the time the differences are in the low bits. But when two scores are close they swap places, and then the replay says the valve stays open. A replay that can disagree with the original isn&#39;t evidence of anything.&lt;/p&gt;
&lt;h2 id=&quot;what-felixd-already-does&quot;&gt;What felixd already does&lt;/h2&gt;
&lt;p&gt;NineLives runs workloads with &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GetFelix/felixd&quot;&gt;felixd&lt;/a&gt;, and part of the answer is already there. A felixd workload is a WebAssembly module, run under wasmtime, whose only inputs are records on Felix streams. It has no clock, no random numbers and no network, so the same records always produce the same state. When a machine is lost, another claims the workload, restores its last snapshot and continues from the recorded inputs, and outputs are written exactly once across that failover. &lt;code&gt;felixd replay&lt;/code&gt; rebuilds a workload at any offset or time, and &lt;code&gt;felixd replay --diff --module&lt;/code&gt; runs a different module over the same inputs and reports the first output that differs.&lt;/p&gt;
&lt;p&gt;So decisions made by ordinary code are already replayable. The model is the part that isn&#39;t.&lt;/p&gt;
&lt;h2 id=&quot;the-idea-the-model-call-is-an-effect&quot;&gt;The idea: the model call is an effect&lt;/h2&gt;
&lt;p&gt;What if the workload never called the model at all? It would write a request instead: the model, pinned by content hash in the workload&#39;s spec, the parameters, and a reference to the input. A separate executor service on a machine with the right hardware would pick it up, run the model, and write the response back as a record, with the hash of the model it actually loaded. The workload would read that response as one more input, in a recorded order.&lt;/p&gt;
&lt;p&gt;On replay, nothing calls the model. The response is read from the log like any other input. It wouldn&#39;t matter that the GPU was swapped, the driver upgraded or the model deleted months ago. The answer the model gave is the answer on the record, so the replay lands on the same decision every time.&lt;/p&gt;
&lt;figure class=&quot;nl-figure&quot;&gt;
  &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; class=&quot;nl-ill&quot; role=&quot;img&quot; aria-label=&quot;A machine labelled model v3 is faded and dashed because it is gone. A faded dashed line runs from it down to one record in the log, which is amber and labelled answer. On the right, the cat sits happily on a machine labelled replay, with a curved arrow from the log up to it.&quot; viewBox=&quot;0 0 720 480&quot; width=&quot;720&quot; height=&quot;480&quot;&gt;&lt;style&gt;.nl-ill{--f-ink:#0e2a3a;--f-soft:rgba(95,211,239,.22);--f-cat:#0e2a3a;--f-eye:#5fd3ef;--f-label:#46536a;--f-amber-text:#8f4c00}
@media (prefers-color-scheme:dark){.nl-ill{--f-ink:#5fd3ef;--f-soft:rgba(18,163,196,.16);--f-cat:#5fd3ef;--f-eye:#0e2a3a;--f-label:#a9b6c8;--f-amber-text:#f7b13a}}
.nl-k{fill:none;stroke:var(--ill-ink,var(--f-ink));stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.nl-m{fill:var(--ill-soft,var(--f-soft));stroke:var(--ill-line,#12a3c4);stroke-width:2;stroke-linejoin:round}
.nl-o{fill:none;stroke:var(--ill-ink,var(--f-ink));stroke-width:2;stroke-linejoin:round}
.nl-ka{fill:none;stroke:var(--ill-accent,#f5a623);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.nl-dead{opacity:.35}
.nl-dash{stroke-dasharray:5 6}
.nl-r{fill:var(--ill-line,#12a3c4)}
.nl-new{fill:var(--ill-accent,#f5a623)}
.nl-lost{opacity:.28}
.nl-cat{fill:var(--ill-cat,var(--f-cat));stroke:var(--ill-ink,var(--f-ink));stroke-width:2;stroke-linejoin:round}
.nl-tail-e{fill:none;stroke:var(--ill-ink,var(--f-ink));stroke-width:10;stroke-linecap:round}
.nl-tail{fill:none;stroke:var(--ill-cat,var(--f-cat));stroke-width:6;stroke-linecap:round}
.nl-eye{fill:var(--ill-eye,var(--f-eye))}
.nl-eye-a{fill:var(--ill-accent,#f5a623)}
.nl-lid{fill:none;stroke:var(--ill-eye,var(--f-eye));stroke-width:2.4;stroke-linecap:round}
.nl-t{font-family:var(--mono,&quot;IBM Plex Mono&quot;,ui-monospace,monospace);font-size:13px;fill:var(--ill-label,var(--f-label))}
.nl-ta{fill:var(--ill-accent-text,var(--f-amber-text))}&lt;/style&gt;&lt;g class=&quot;nl-dead&quot;&gt;&lt;rect class=&quot;nl-o nl-dash&quot; x=&quot;70&quot; y=&quot;110&quot; width=&quot;180&quot; height=&quot;100&quot; rx=&quot;12&quot;/&gt;&lt;path class=&quot;nl-k nl-dash&quot; d=&quot;M86 128h26M86 138h16&quot;/&gt;&lt;text class=&quot;nl-t&quot; x=&quot;86&quot; y=&quot;194&quot; text-anchor=&quot;start&quot;&gt;model v3&lt;/text&gt;&lt;/g&gt;&lt;g class=&quot;nl-dead&quot;&gt;&lt;path class=&quot;nl-k nl-dash&quot; d=&quot;M160 212C160 310 379 296 379 372&quot;/&gt;&lt;/g&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;200&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;228&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;256&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;284&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;312&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;340&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;368&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;396&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;424&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;452&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-new&quot; x=&quot;368&quot; y=&quot;380&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;text class=&quot;nl-t nl-ta&quot; x=&quot;379&quot; y=&quot;416&quot; text-anchor=&quot;middle&quot;&gt;answer&lt;/text&gt;&lt;text class=&quot;nl-t&quot; x=&quot;185&quot; y=&quot;392&quot; text-anchor=&quot;end&quot;&gt;log&lt;/text&gt;&lt;rect class=&quot;nl-m&quot; x=&quot;470&quot; y=&quot;160&quot; width=&quot;190&quot; height=&quot;100&quot; rx=&quot;12&quot;/&gt;&lt;path class=&quot;nl-k&quot; d=&quot;M486 178h26M486 188h16&quot;/&gt;&lt;text class=&quot;nl-t&quot; x=&quot;486&quot; y=&quot;244&quot; text-anchor=&quot;start&quot;&gt;replay&lt;/text&gt;&lt;path class=&quot;nl-k&quot; d=&quot;M450 372C470 320 530 300 548 268M548.3 279.0L548 268L538.4 273.4&quot;/&gt;&lt;path class=&quot;nl-tail-e&quot; d=&quot;M582 144C588 162 582 176 568 181&quot;/&gt;&lt;path class=&quot;nl-tail&quot; d=&quot;M582 144C588 162 582 176 568 181&quot;/&gt;&lt;path class=&quot;nl-cat&quot; d=&quot;M547 140C547 129 550 123 553 120L551 106L562 117Q565 116 568 117L579 106L577 120C580 123 583 129 583 140C583 152 575 158 565 158C555 158 547 152 547 140Z&quot;/&gt;&lt;path class=&quot;nl-lid&quot; d=&quot;M554 141Q558 135 562 141&quot;/&gt;&lt;path class=&quot;nl-lid&quot; d=&quot;M568 141Q572 135 576 141&quot;/&gt;&lt;/svg&gt;
  &lt;figcaption&gt;The model that made the call is gone, but its answer is a record. Replay reads it instead of asking again.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Keeping the executor out of felixd matters too. A driver fault or a model eating all the memory would crash the executor, not stall felixd&#39;s lease renewals, so a sick GPU wouldn&#39;t look like a lost machine.&lt;/p&gt;
&lt;p&gt;The same log carries everything around the decision. The &lt;a rel=&quot;external&quot; href=&quot;https://getfelix.dev/blog/what-if-your-fleet-ran-on-one-log.html&quot;&gt;companion post on getfelix.dev&lt;/a&gt; looks at the Felix side: the devices&#39; readings, the model&#39;s responses and the commands back out all on one log, under the same rules.&lt;/p&gt;
&lt;h2 id=&quot;asking-why&quot;&gt;Asking why&lt;/h2&gt;
&lt;p&gt;With the response recorded, &quot;why did the valve close?&quot; has a mechanical answer. The close command names the step that produced it. That step&#39;s input was a model response, which names the model hash, the parameters and the request. The request names the sensor reading it was about. Replaying from the snapshot before that reading reproduces the decision exactly.&lt;/p&gt;
&lt;p&gt;That only works while the records are still there. Once a snapshot covers a response, resuming no longer needs it, and felixd&#39;s trim floors would let it go. That suits recovery and not an audit. So each workload would state an audit horizon in its spec, and Felix&#39;s head trim would never cut its responses below it. How long a decision stays explainable would be a choice someone wrote down, not a side effect of snapshot retention.&lt;/p&gt;
&lt;h2 id=&quot;trying-a-new-model-on-old-history&quot;&gt;Trying a new model on old history&lt;/h2&gt;
&lt;p&gt;A new model can&#39;t reuse old answers, since every request would name a different hash. But the recorded requests are still there. Send them to the new model, write its answers to a separate stream, run the workload over them, and diff the decisions against what actually happened. You&#39;d see the first decision that changes and how many do. The new answers are recorded too, so the comparison can be replayed and cited later.&lt;/p&gt;
&lt;figure class=&quot;nl-figure&quot;&gt;
  &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; class=&quot;nl-ill&quot; role=&quot;img&quot; aria-label=&quot;One log of recorded inputs feeds two machines. The left one, v3 live, has the cat on it and its decisions are acted on. The right one, v4 shadow, is an empty outline and its decisions are recorded only. In the shadow&amp;#39;s row of decisions, the fourth is amber and labelled first diff.&quot; viewBox=&quot;0 0 720 480&quot; width=&quot;720&quot; height=&quot;480&quot;&gt;&lt;style&gt;.nl-ill{--f-ink:#0e2a3a;--f-soft:rgba(95,211,239,.22);--f-cat:#0e2a3a;--f-eye:#5fd3ef;--f-label:#46536a;--f-amber-text:#8f4c00}
@media (prefers-color-scheme:dark){.nl-ill{--f-ink:#5fd3ef;--f-soft:rgba(18,163,196,.16);--f-cat:#5fd3ef;--f-eye:#0e2a3a;--f-label:#a9b6c8;--f-amber-text:#f7b13a}}
.nl-k{fill:none;stroke:var(--ill-ink,var(--f-ink));stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.nl-m{fill:var(--ill-soft,var(--f-soft));stroke:var(--ill-line,#12a3c4);stroke-width:2;stroke-linejoin:round}
.nl-o{fill:none;stroke:var(--ill-ink,var(--f-ink));stroke-width:2;stroke-linejoin:round}
.nl-ka{fill:none;stroke:var(--ill-accent,#f5a623);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.nl-dead{opacity:.35}
.nl-dash{stroke-dasharray:5 6}
.nl-r{fill:var(--ill-line,#12a3c4)}
.nl-new{fill:var(--ill-accent,#f5a623)}
.nl-lost{opacity:.28}
.nl-cat{fill:var(--ill-cat,var(--f-cat));stroke:var(--ill-ink,var(--f-ink));stroke-width:2;stroke-linejoin:round}
.nl-tail-e{fill:none;stroke:var(--ill-ink,var(--f-ink));stroke-width:10;stroke-linecap:round}
.nl-tail{fill:none;stroke:var(--ill-cat,var(--f-cat));stroke-width:6;stroke-linecap:round}
.nl-eye{fill:var(--ill-eye,var(--f-eye))}
.nl-eye-a{fill:var(--ill-accent,#f5a623)}
.nl-lid{fill:none;stroke:var(--ill-eye,var(--f-eye));stroke-width:2.4;stroke-linecap:round}
.nl-t{font-family:var(--mono,&quot;IBM Plex Mono&quot;,ui-monospace,monospace);font-size:13px;fill:var(--ill-label,var(--f-label))}
.nl-ta{fill:var(--ill-accent-text,var(--f-amber-text))}&lt;/style&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;234&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;262&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;290&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;318&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;346&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;374&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;402&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;430&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;458&quot; y=&quot;70&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;text class=&quot;nl-t&quot; x=&quot;219&quot; y=&quot;82&quot; text-anchor=&quot;end&quot;&gt;log&lt;/text&gt;&lt;path class=&quot;nl-k&quot; d=&quot;M300 96C260 126 150 120 130 182M127.5 171.3L130 182L138.3 174.8&quot;/&gt;&lt;path class=&quot;nl-k&quot; d=&quot;M390 96C420 140 500 140 520 180M510.7 174.1L520 180L520.9 169.0&quot;/&gt;&lt;rect class=&quot;nl-m&quot; x=&quot;90&quot; y=&quot;190&quot; width=&quot;220&quot; height=&quot;100&quot; rx=&quot;12&quot;/&gt;&lt;path class=&quot;nl-k&quot; d=&quot;M106 208h26M106 218h16&quot;/&gt;&lt;text class=&quot;nl-t&quot; x=&quot;106&quot; y=&quot;274&quot; text-anchor=&quot;start&quot;&gt;v3 · live&lt;/text&gt;&lt;rect class=&quot;nl-o&quot; x=&quot;410&quot; y=&quot;190&quot; width=&quot;220&quot; height=&quot;100&quot; rx=&quot;12&quot;/&gt;&lt;path class=&quot;nl-k&quot; d=&quot;M426 208h26M426 218h16&quot;/&gt;&lt;text class=&quot;nl-t&quot; x=&quot;426&quot; y=&quot;274&quot; text-anchor=&quot;start&quot;&gt;v4 · shadow&lt;/text&gt;&lt;path class=&quot;nl-tail-e&quot; d=&quot;M273 174C279 192 273 206 259 211&quot;/&gt;&lt;path class=&quot;nl-tail&quot; d=&quot;M273 174C279 192 273 206 259 211&quot;/&gt;&lt;path class=&quot;nl-cat&quot; d=&quot;M238 170C238 159 241 153 244 150L242 136L253 147Q256 146 259 147L270 136L268 150C271 153 274 159 274 170C274 182 266 188 256 188C246 188 238 182 238 170Z&quot;/&gt;&lt;ellipse class=&quot;nl-eye&quot; cx=&quot;249&quot; cy=&quot;169&quot; rx=&quot;4.2&quot; ry=&quot;2.8&quot;/&gt;&lt;ellipse class=&quot;nl-eye&quot; cx=&quot;263&quot; cy=&quot;169&quot; rx=&quot;4.2&quot; ry=&quot;2.8&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;130&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;158&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;186&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;214&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;242&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;450&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;478&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;506&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;534&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-r&quot; x=&quot;562&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;rect class=&quot;nl-new&quot; x=&quot;534&quot; y=&quot;340&quot; width=&quot;22&quot; height=&quot;14&quot; rx=&quot;5&quot;/&gt;&lt;text class=&quot;nl-t nl-ta&quot; x=&quot;545&quot; y=&quot;376&quot; text-anchor=&quot;middle&quot;&gt;first diff&lt;/text&gt;&lt;text class=&quot;nl-t&quot; x=&quot;200&quot; y=&quot;420&quot; text-anchor=&quot;middle&quot;&gt;acted on&lt;/text&gt;&lt;text class=&quot;nl-t&quot; x=&quot;520&quot; y=&quot;420&quot; text-anchor=&quot;middle&quot;&gt;recorded only&lt;/text&gt;&lt;/svg&gt;
  &lt;figcaption&gt;Shadowing a new model: same recorded inputs, its decisions recorded but never acted on, and the first one that differs pointed out.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;After that the new model could shadow live traffic for a while, recording answers and acting on none, and then take over at a recorded input offset. Every node would switch at the same point, even across a failover.&lt;/p&gt;
&lt;h2 id=&quot;acting-once-or-saying-you-don-t-know&quot;&gt;Acting once, or saying you don&#39;t know&lt;/h2&gt;
&lt;p&gt;Asking a model twice is harmless. Opening a valve twice may not be. A command to a device would be an effect too, and felixd already writes each output once and refuses a stale owner. What&#39;s left is the executor that fails halfway. So it would record that it accepted a command before acting. If nobody accepted the command by its deadline, the outcome is a timeout: nothing happened, and it&#39;s safe to try again. If it was accepted and the executor was lost, the outcome is unknown, meaning it may have acted. That gets recorded as exactly that, never guessed into success or failure, and replay sees the same thing.&lt;/p&gt;
&lt;p&gt;What to do about unknown is up to the workload. &quot;Set the valve to 40%&quot; is safe to repeat where &quot;open it 10% more&quot; isn&#39;t. A workload can wait for the valve&#39;s next reading before deciding, a gateway can refuse a command id it has already seen, and a device that can do neither stops and asks a person.&lt;/p&gt;
&lt;h2 id=&quot;a-smaller-option-the-model-inside-the-step&quot;&gt;A smaller option: the model inside the step&lt;/h2&gt;
&lt;p&gt;Some models are small enough to skip the round trip: a classifier, an anomaly detector. Those could run inside the WebAssembly step itself. Wasm floating point is deterministic apart from NaN bit patterns, which wasmtime can canonicalize, so a model with no threads, no relaxed SIMD and its weights covered by the module hash would land on the same bits on every replay. Integer-quantized models suit it best. It&#39;s slower than native code and has no GPU, but it keeps working on a device that has lost its link. A device could run the small model locally and send an effect to a larger one only when it&#39;s unsure.&lt;/p&gt;
&lt;h2 id=&quot;what-i-don-t-know-yet&quot;&gt;What I don&#39;t know yet&lt;/h2&gt;
&lt;p&gt;Most of this depends on pieces felixd doesn&#39;t have: effects with typed outcomes, a store for model files far too big to be single records, and a determinism contract strict enough to promise the same bits for the in-step models. A round trip through the log costs milliseconds, so a control loop that has to close faster than that stays on the device. And a site with a poor link needs a way to get its records to the rest of the fleet later.&lt;/p&gt;
&lt;p&gt;If you run models on machines that act on the world and have opinions about any of this, the &lt;a rel=&quot;external&quot; href=&quot;https://github.com/GetFelix/ninelives/blob/main/docs/inference-as-effect.md&quot;&gt;design&lt;/a&gt; and its open questions are the place to argue.&lt;/p&gt;
</content>
  </entry>
</feed>
