<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pavel Kazantsev 💎</title>
    <description>The latest articles on DEV Community by Pavel Kazantsev 💎 (@pavel_kkkkazantsev).</description>
    <link>https://dev.to/pavel_kkkkazantsev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4134503%2Ffa19808d-55bb-4682-9e96-cb3962636da8.PNG</url>
      <title>DEV Community: Pavel Kazantsev 💎</title>
      <link>https://dev.to/pavel_kkkkazantsev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pavel_kkkkazantsev"/>
    <language>en</language>
    <item>
      <title>I Went Looking for the Dead Internet in My RAG. I Found a Bad Experiment.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Tue, 03 Nov 2026 08:36:33 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/i-went-looking-for-the-dead-internet-in-my-rag-i-found-a-bad-experiment-1cjh</link>
      <guid>https://dev.to/pavel_kkkkazantsev/i-went-looking-for-the-dead-internet-in-my-rag-i-found-a-bad-experiment-1cjh</guid>
      <description>&lt;p&gt;I removed duplicate sources from a RAG benchmark and watched held-out accuracy fall from 10 of 20 tasks to 5 of 20. The tempting conclusion was that source deduplication made retrieval worse.&lt;/p&gt;

&lt;p&gt;The audit found another change hiding in the comparison. Lexical top-k supplied roughly five documents; the collapse policy often supplied one or two. The run changed source diversity and the evidence budget together, so the accuracy drop could not identify a provenance effect.&lt;/p&gt;

&lt;p&gt;With a fixed-budget control, root-aware retrieval recovered the primary record and accuracy returned to 10 of 20. It did not improve further. That correction became the point of this investigation: retrieval correctness and answer correctness are different measurements, and an apparent result can disappear when the control is fair.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removing duplicate sources appeared to halve accuracy, but the comparison also reduced the evidence budget.&lt;/li&gt;
&lt;li&gt;Under the corrected control, root-aware retrieval recovered primary records without improving answer accuracy.&lt;/li&gt;
&lt;li&gt;A 15-case real-world pilot validated the provenance pipeline, not retrieval effectiveness. V2 will test adversarial cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Part 1 corrects a misleading synthetic result and validates a provenance-capture and evaluation pipeline on real sources. Part 2 will test whether provenance-aware retrieval changes answers when the evidence actually conflicts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqocb3fy1f9cuyrg7fjv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqocb3fy1f9cuyrg7fjv.png" alt="Many browser pages trace back to one illuminated source record. Editorial illustration, not evidence." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part of the "dead internet" idea worth testing
&lt;/h2&gt;

&lt;p&gt;The phrase "dead internet" is associated with a 2021 forum post, &lt;em&gt;Dead Internet Theory: Most of the Internet Is Fake&lt;/em&gt;. &lt;a href="https://www.livescience.com/technology/artificial-intelligence/what-is-the-dead-internet-conspiracy" rel="noopener noreferrer"&gt;Contemporary reporting traces the popular term to that thread&lt;/a&gt;. Its broad version bundles together bots, manufactured engagement, algorithms, and a claim that real human activity has become marginal.&lt;/p&gt;

&lt;p&gt;That claim is too broad for this experiment. A traffic statistic cannot establish who wrote a page, whether a claim is true, or whether people have stopped participating online.&lt;/p&gt;

&lt;p&gt;For RAG, a systems problem remains. &lt;a href="https://www.imperva.com/resources/resource-library/reports/2025-bad-bot-report/" rel="noopener noreferrer"&gt;Imperva's 2025 report&lt;/a&gt; estimated that automation represented 51% of the web traffic it observed in 2024. The category includes useful crawlers and malicious bots, so it cannot count a "fake internet."&lt;/p&gt;

&lt;p&gt;For retrieval, a smaller question is enough: how many independent source roots support the evidence in context?&lt;/p&gt;

&lt;p&gt;Repeated documents can reach an index through a documented syndication chain, a press release, a scrape, a summary, or a generated rewrite. The causes differ. The failure is the same when repetition reaches the model as apparent corroboration.&lt;/p&gt;

&lt;p&gt;The wider theory also tends to mix four questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did a human or an automated client make the request?&lt;/li&gt;
&lt;li&gt;Was the page written by a person, copied, or generated?&lt;/li&gt;
&lt;li&gt;Where did the claim originate?&lt;/li&gt;
&lt;li&gt;Does the claim have enough support to warrant an answer?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They require different evidence. This experiment addresses the third question, provenance. It does not diagnose the web as a whole.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I mean by a source root
&lt;/h2&gt;

&lt;p&gt;A source root is an information lineage, not a hostname. One root can include a public primary record and several documented syndications. One publisher can contain several independent roots.&lt;/p&gt;

&lt;p&gt;I assign a root only when the relationship has evidence: a canonical link, a syndication notice, an explicit citation, or a documented editorial relationship. Near-identical phrasing is useful for discovery, but it does not prove copying. Treating a similarity score as provenance would create a confident-looking field from an unverified assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system
&lt;/h2&gt;

&lt;p&gt;The implementation keeps evidence capture, retrieval, generation, and scoring separate. Otherwise one &lt;code&gt;provenance_aware&lt;/code&gt; switch can quietly change the candidate pool, context length, answer rule, and source metadata at once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwqbmufyukftjaiudjc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwqbmufyukftjaiudjc7.png" alt="Evidence capture, fixed-budget retrieval, generation, and evaluation are separate stages in the pipeline." width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Evidence capture saves the public and canonical URLs, timestamp, HTTP status, extracted text, hash, and documented relation. Retrieval selects documents under a defined budget. Generation receives document text, IDs, roots, and roles, but never an expected stance or fixture label. Evaluation checks the returned stance, citation IDs, and retrieval trace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lexical_retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;root_aware_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fixed_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_stance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;pool=50&lt;/code&gt; is important. Collapsing sources only after a top-five cutoff cannot recover a primary record that was ranked sixth. Candidate grouping has to happen before the context budget is spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result that fooled me
&lt;/h2&gt;

&lt;p&gt;I built a 60-task synthetic fixture with known source roots. The first comparison looked clean: lexical top-k got 10 of 20 held-out tasks right; a root-collapse policy got 5 of 20.&lt;/p&gt;

&lt;p&gt;The comparison was not clean. Root collapse sometimes returned one or two documents where lexical top-k returned five. Both variables changed, so the first run cannot attribute its regression to source grouping alone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Held-out synthetic control, k = 5&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Mean documents&lt;/th&gt;
&lt;th&gt;Primary-record recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lexical top-k&lt;/td&gt;
&lt;td&gt;10 / 20&lt;/td&gt;
&lt;td&gt;4.25&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post-top-k collapse&lt;/td&gt;
&lt;td&gt;5 / 20&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root-aware fill&lt;/td&gt;
&lt;td&gt;10 / 20&lt;/td&gt;
&lt;td&gt;4.25&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflict-preserving fill&lt;/td&gt;
&lt;td&gt;10 / 20&lt;/td&gt;
&lt;td&gt;4.25&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogsfatw0swe62mpj34t4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogsfatw0swe62mpj34t4.png" alt="A three-column comparison: lexical top-k and root-aware fill preserve five document slots, while post-top-k collapse leaves a mean of 1.15 documents. The latter changes source diversity and evidence budget at once." width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The original collapse policy spent fewer evidence slots. Root-aware fill chooses roots from a larger pool, then restores the intended document budget.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With the budget fixed, root-aware fill recovered the primary record and retained the deterministic accuracy of lexical top-k. It did not improve accuracy. The remaining slots still let the answer rule count repeated stances.&lt;/p&gt;

&lt;p&gt;The original drop measured a policy that also shrank the prompt. It did not isolate the effect of source grouping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why keep the synthetic fixture?
&lt;/h2&gt;

&lt;p&gt;The fixture isolates a retrieval mechanism that is hard to control on public pages. It contains 60 tasks across duplicate dilution, true repetition, conflicting roots, and satire stripped of its context. Each category has ten development tasks and five held-out tasks.&lt;/p&gt;

&lt;p&gt;The publishers and documents are fictional because the fixture needs declared roots and known answers without claiming to measure the open web. The first experiment uses a deterministic stance aggregator, making the intervention inspectable. It also exposes a limitation: recovering a primary record does not automatically improve the final answer if the aggregator still counts every retained document equally.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftwrwxfo8qdxfeoppcavl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftwrwxfo8qdxfeoppcavl.png" alt="Held-out false-dilution fixture trace: lexical top-five returns five mirror documents from one root and misses the primary specification; root-aware fill includes the primary while retaining five document slots." width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A concrete held-out fixture trace. It demonstrates a retrieval change, not a real-world effectiveness result.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fixtures cannot show whether the pipeline works on public pages, so I collected a separate real-data pilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-case real pilot
&lt;/h2&gt;

&lt;p&gt;The pilot contains 15 NASA-to-Phys.org syndication chains. Every accepted case has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a public NASA record;&lt;/li&gt;
&lt;li&gt;a Phys.org page whose snapshot explicitly says &lt;code&gt;Provided by NASA&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;timestamped HTML, extracted text, a canonical URL, HTTP status, and a SHA-256 content hash;&lt;/li&gt;
&lt;li&gt;a graph edge supported by the documented syndication marker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Similar language never counted as proof of dependence. If the source relationship was undocumented, the case did not enter the slice.&lt;/p&gt;

&lt;p&gt;Gemma 3 4B and Qwen 2.5 7B independently labelled document roles. They disagreed enough that I did not use their votes as ground truth. Final roles came from the explicit record type and syndication marker; model annotations remain in the corpus as machine annotations. This is a machine-adjudicated pilot, not a human-verified dataset.&lt;/p&gt;

&lt;p&gt;I ran both models on the same 15 questions under four conditions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;primary-only;&lt;/li&gt;
&lt;li&gt;primary plus the documented dependent copy;&lt;/li&gt;
&lt;li&gt;root-aware;&lt;/li&gt;
&lt;li&gt;conflict-preserving root-aware.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prompt, seed, decoding settings, evidence ordering, retrieval trace, and raw responses were saved for every run. Neither model received the expected answer or a stance label in its prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What V1 actually found&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15 source chains · 2 local models · 120 retained responses&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemma 3 4B and Qwen 2.5 7B each returned the correct stance for all 15 cases in every condition. Citation-ID validity changed when the dependent copy was present. The slice has one root and positive support in every case, so it validates provenance capture and evaluation plumbing. It does not test whether root-aware retrieval prevents a real answer error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both models answered all 15 questions correctly in every condition.&lt;/p&gt;

&lt;p&gt;Every accepted chain has one source root and supports one positive claim, so root-aware retrieval reduces to primary-only. The slice has no false assertion or contradictory root to resolve.&lt;/p&gt;

&lt;p&gt;The citation trace moved:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Primary-only valid citation IDs&lt;/th&gt;
&lt;th&gt;With dependent copy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 4B&lt;/td&gt;
&lt;td&gt;3 / 15&lt;/td&gt;
&lt;td&gt;6 / 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen 2.5 7B&lt;/td&gt;
&lt;td&gt;12 / 15&lt;/td&gt;
&lt;td&gt;10 / 15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf7bdf84sq0dkf3vr1ak.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf7bdf84sq0dkf3vr1ak.png" alt="Citation validity in the 15-case real pilot." width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those are exploratory counts, not a model ranking. Citation-ID validity only says whether the returned ID was present in supplied context. It does not establish that the cited document supports the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The V1 confound, stated plainly
&lt;/h2&gt;

&lt;p&gt;There is a second limit on the V1 comparison. Adding the dependent page changes the context from one document to two, and Ollama's recorded prompt-token count changes in every model-case pair.&lt;/p&gt;

&lt;p&gt;The citation results are descriptive because the comparison cannot isolate source dependence from the extra context. V2 will block an evaluation unless each paired case has equal document and prompt-token budgets.&lt;/p&gt;

&lt;p&gt;The real corpus also has a ceiling effect. Primary-reference recall is 100% by construction in the one-root conditions, and root concentration is 1.0 because every selected context belongs to one lineage. These measurements confirm that the policy did what it was asked to do. They also explain why the pilot cannot support an accuracy claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics worth keeping separate
&lt;/h2&gt;

&lt;p&gt;Correctness is useful, but it can hide the failure that changed. A model may preserve a correct stance while losing the primary record, naming a citation ID that was never in context, or becoming impossible to audit after a page changes.&lt;/p&gt;

&lt;p&gt;I would report at least these outcomes for a larger corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;answer stance;&lt;/li&gt;
&lt;li&gt;primary-reference recall;&lt;/li&gt;
&lt;li&gt;root concentration;&lt;/li&gt;
&lt;li&gt;citation validity;&lt;/li&gt;
&lt;li&gt;contradiction coverage;&lt;/li&gt;
&lt;li&gt;false-claim adoption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single combined score would hide which part failed. The unit for a future comparison is the source case, not the generated response. Paired accuracy deltas, bootstrap intervals, and an exact sign test make sense only after enough independent held-out cases survive the admission gate.&lt;/p&gt;

&lt;p&gt;For production, I would retain this trace beside every retrieved chunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;document_id · canonical_url · publisher · publication_date
retrieved_at · content_hash · source_root · source_role
claim_relation · dependency_evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Candidate selection, source weighting, and conflict handling should stay separate. A source root can come from a documented origin or explicit citation evidence. It cannot come from cosine similarity alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a reader can verify today
&lt;/h2&gt;

&lt;p&gt;The public release supports artifact verification, not a byte-for-byte end-to-end rerun. It contains public URLs, hashes, annotations, the run manifest, the paired-budget audit, and 120 raw model responses. It deliberately excludes third-party HTML and extracted page text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/icesurf666/rag-provenance-lab.git
&lt;span class="nb"&gt;cd &lt;/span&gt;rag-provenance-lab
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.[test]'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/icesurf666/rag-provenance-lab/blob/main/REPRODUCIBILITY.md" rel="noopener noreferrer"&gt;reproducibility guide&lt;/a&gt; explains that boundary. The &lt;a href="https://github.com/icesurf666/rag-provenance-lab/blob/main/reports/V1_PAIRED_BUDGET_AUDIT.json" rel="noopener noreferrer"&gt;paired-budget audit&lt;/a&gt; records all 30 unequal model-case pairs; the &lt;a href="https://github.com/icesurf666/rag-provenance-lab/blob/main/data/public/v1/v1_raw_model_responses.jsonl" rel="noopener noreferrer"&gt;raw responses&lt;/a&gt;, &lt;a href="https://github.com/icesurf666/rag-provenance-lab/blob/main/data/public/v1/v1_run_manifest.json" rel="noopener noreferrer"&gt;run manifest&lt;/a&gt;, and &lt;a href="https://github.com/icesurf666/rag-provenance-lab/blob/main/reports/REAL_PILOT_RESULTS.md" rel="noopener noreferrer"&gt;recorded results&lt;/a&gt; are public.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real retrieval failure outside this pilot
&lt;/h2&gt;

&lt;p&gt;In May 2024, Google AI Overviews advised users to add glue to pizza sauce. &lt;a href="https://apnews.com/article/33060569d6cc01abe6c63d21665330d8" rel="noopener noreferrer"&gt;Associated Press reported&lt;/a&gt; that the answer traced back to a satirical Reddit comment, and &lt;a href="https://blog.google/products-and-platforms/products/search/ai-overviews-update-may-2024/" rel="noopener noreferrer"&gt;Google later published its response&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The incident shows a retrieval failure: the words reached the system while the sentence that explained their status did not. It cannot estimate a dead web or the prevalence of AI-generated content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next: V2 — when provenance changes the answer
&lt;/h2&gt;

&lt;p&gt;V1 is complete as a pipeline validation. V2 will test the effectiveness question it leaves open: can source provenance prevent a model from accepting a repeated false claim when a correct primary record is available?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjw53hwd56evy1qtcjj15.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjw53hwd56evy1qtcjj15.png" alt="V1 contains fifteen one-root NASA to Phys.org positive cases and validates capture, traces, and paired execution. V2 is planned to test corrections, stale copies, satire, conflicts, and independent corroboration under fixed budgets." width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;V1 and V2 answer different questions. This diagram describes a planned V2 protocol, not results.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The next corpus will include corrections alongside persistent stale copies, satire retold as fact, contradictory evidence, genuinely independent corroboration, and conflicts within one source lineage. The target is 40 to 60 quality-gated real cases with documented provenance, snapshots, hashes, independent machine annotations, and adjudication.&lt;/p&gt;

&lt;p&gt;Every case that passes the gate will stay in the evaluation set. V2 will include cases where both policies are correct, both are wrong, lexical retrieval wins, and root-aware retrieval wins. Selecting only later success stories would turn the corpus into a demonstration instead of an experiment.&lt;/p&gt;

&lt;p&gt;Gemma and Qwen will run pre-specified paired conditions with equal document and prompt-token budgets. Document-order permutations will be assigned before outcomes are inspected. The result may show an improvement, a regression, or no material effect.&lt;/p&gt;

&lt;p&gt;Until V2 exists, a claim that root-aware retrieval improves real-world answer accuracy would be premature.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/icesurf666/rag-provenance-lab" rel="noopener noreferrer"&gt;RAG Provenance Lab repository&lt;/a&gt; contains the public V1 code, artifact-verification materials, model outputs, run manifests, and the protocol. It distinguishes artifact verification from end-to-end reproduction: third-party page snapshots and extracted text are not distributed in the public release.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>I Tested 7 Crypto Models. The Best Signal Failed the Cost Test.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:17:47 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/i-tested-7-crypto-models-the-best-signal-failed-the-cost-test-2maa</link>
      <guid>https://dev.to/pavel_kkkkazantsev/i-tested-7-crypto-models-the-best-signal-failed-the-cost-test-2maa</guid>
      <description>&lt;p&gt;Huber regression had the strongest predictive ranking in my crypto model comparison: mean out-of-sample Spearman IC &lt;strong&gt;+0.055&lt;/strong&gt;. Its selected trades nevertheless averaged &lt;strong&gt;−7.74 basis points after modeled costs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The tree model with the least negative result was an XGBoost configuration at −1.73 bps. Its uncertainty interval crossed zero. LightGBM was close behind at −1.93 bps. Neither result establishes a profitable strategy.&lt;/p&gt;

&lt;p&gt;Those numbers came only after an uncomfortable discovery: the first version of the backtest had a boundary leak and treated gaps in price history as consecutive bars. We fixed both and reran the comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Research status:&lt;/strong&gt; this is exploratory, retrospective research on history already examined in earlier work. Prices are OHLC proxies, costs are assumed, the universe was selected retrospectively, and there is no executable portfolio P&amp;amp;L. It is not an independent holdout or a live-trading result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal is not a strategy
&lt;/h2&gt;

&lt;p&gt;The question began with three features: 1-, 5-, and 20-bar log returns on 15-minute Bybit perpetual candles. Could a model use them to rank the next return? Could a trading rule survive costs?&lt;/p&gt;

&lt;p&gt;An information coefficient, or IC, describes rank correlation between a score and the future return. To assess a trading rule, we also need to know how often it trades, what each trade earns and what execution costs.&lt;/p&gt;

&lt;p&gt;Here IC is one Spearman correlation pooled across available symbol-bar observations per fold, then averaged equally across folds. It is not a daily cross-sectional IC; pooled correlations can reflect differences between symbols as well as within-symbol ranking. We did not estimate an IC confidence interval in this run.&lt;/p&gt;

&lt;p&gt;Why can the rankings differ? IC uses all scored observations to ask about ordering. The trading result uses only observations beyond a model-specific threshold, assigns a direction and subtracts costs. A model can rank the whole panel well yet select a less rewarding set of extreme scores. The two statistics also weight observations differently, so this experiment does not isolate a single cause of the gap.&lt;/p&gt;

&lt;p&gt;An earlier OLS study reported 4,218 trades and +5.45 bps mean net using a fixed 12 bps round-trip assumption and a fixed ±12 bps signal threshold. Its bootstrap implementation also had a bug: repeated sampled days were discarded. The reported interval could not be relied on. A positive number did not answer whether another model, evaluated under the same rules, would do better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven candidates, one historical protocol
&lt;/h2&gt;

&lt;p&gt;The original comparison used reversal, OLS, Ridge, Huber and constrained LightGBM. I later added XGBoost and CatBoost as a &lt;strong&gt;post-hoc&lt;/strong&gt; extension. The seven configurations share the same time splits, universe, features available to learned models, signal-selection rule and modeled costs. The reversal baseline uses only the one-bar feature.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; 16 Bybit USDT perpetuals; 15-minute bars; symbols enter as data becomes available. Selecting this universe retrospectively creates survivorship risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluation:&lt;/strong&gt; six expanding walk-forward folds, 2022 H1 through 2024 H2; training starts in 2021.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target:&lt;/strong&gt; next-bar close-to-close return in bps. The model and scaler are fitted on training data only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signal:&lt;/strong&gt; a long or short when the score exceeds the 99th percentile of its own training score magnitude in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Economics:&lt;/strong&gt; next-bar open-to-close return, less modeled round-trip costs of 13, 15 or 19 bps by liquidity tier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost assumption is 5.5 bps taker fee per side plus 1, 2 or 4 bps assumed slippage per side. These are inputs to the historical proxy, not measured historical fees or fills.&lt;/p&gt;

&lt;p&gt;The threshold helps make differently scaled scores comparable, but it does not force models to trade equally often in the evaluation period. Models may select different symbols and dates, so their mean per-trade returns are not returns on an equally allocated portfolio.&lt;/p&gt;

&lt;p&gt;The tree configurations are fixed and untuned here; they use different algorithms and regularization. This is a comparison of seven configurations, not a universal ranking of model families.&lt;/p&gt;

&lt;p&gt;The later boosters each use 200 trees, depth 4 and learning rate 0.03. XGBoost uses &lt;code&gt;min_child_weight=2000&lt;/code&gt;, L1=1 and L2=10; CatBoost uses L2=10. Ridge uses a strong fixed &lt;code&gt;alpha=1,000,000&lt;/code&gt; on standardized features, so its effective regularization changes relative to the growing training sample. A different tuning budget could change the ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five equations and one timing problem
&lt;/h2&gt;

&lt;p&gt;For bar close &lt;code&gt;C[t]&lt;/code&gt; and open &lt;code&gt;O[t]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;features:    x[t, k] = log(C[t] / C[t-k]), k ∈ {1, 5, 20}
train label: y[t]    = 10,000 × (C[t+1] / C[t] − 1)
threshold:   q       = 99th percentile of |training score|
signal:      s[t]    = +1 if score &amp;gt; q; −1 if score &amp;lt; −q; else 0
trade net:   p[t]    = s[t] × 10,000 × (C[t+1] / O[t+1] − 1) − cost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last equation applies to selected trades only. A basis point is 0.01% of position notional.&lt;/p&gt;

&lt;p&gt;The model learns close-to-close returns, while the economic proxy measures the next candle's open-to-close move. The next open can occur before a system reacts to a confirmed close, so the OHLC price does not establish that a trade could have filled there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp938wbvcoil2ouftzzwt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp938wbvcoil2ouftzzwt.png" alt="Illustrative timeline showing the candle boundary, confirmation, model decision and proxy exit" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The timing diagram is illustrative, not a measured latency distribution. A later bid/ask observation does not establish that an order filled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corrected results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Mean fold IC&lt;/th&gt;
&lt;th&gt;Trade-weighted net / trade&lt;/th&gt;
&lt;th&gt;95% block interval, bps&lt;/th&gt;
&lt;th&gt;Trades&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XGBoost*&lt;/td&gt;
&lt;td&gt;+0.012&lt;/td&gt;
&lt;td&gt;−1.73 bps&lt;/td&gt;
&lt;td&gt;[−7.41, +3.63]&lt;/td&gt;
&lt;td&gt;10,885&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LightGBM&lt;/td&gt;
&lt;td&gt;+0.012&lt;/td&gt;
&lt;td&gt;−1.93 bps&lt;/td&gt;
&lt;td&gt;[−7.64, +3.65]&lt;/td&gt;
&lt;td&gt;11,006&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CatBoost*&lt;/td&gt;
&lt;td&gt;+0.011&lt;/td&gt;
&lt;td&gt;−5.73 bps&lt;/td&gt;
&lt;td&gt;[−11.54, +0.83]&lt;/td&gt;
&lt;td&gt;11,341&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ridge&lt;/td&gt;
&lt;td&gt;+0.044&lt;/td&gt;
&lt;td&gt;−7.14 bps&lt;/td&gt;
&lt;td&gt;[−14.34, −0.76]&lt;/td&gt;
&lt;td&gt;12,685&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Huber&lt;/td&gt;
&lt;td&gt;+0.055&lt;/td&gt;
&lt;td&gt;−7.74 bps&lt;/td&gt;
&lt;td&gt;[−15.34, −0.88]&lt;/td&gt;
&lt;td&gt;12,487&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OLS&lt;/td&gt;
&lt;td&gt;+0.036&lt;/td&gt;
&lt;td&gt;−9.82 bps&lt;/td&gt;
&lt;td&gt;[−16.00, −4.42]&lt;/td&gt;
&lt;td&gt;12,461&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-bar reversal&lt;/td&gt;
&lt;td&gt;+0.040&lt;/td&gt;
&lt;td&gt;−12.55 bps&lt;/td&gt;
&lt;td&gt;[−18.39, −6.48]&lt;/td&gt;
&lt;td&gt;11,497&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;XGBoost and CatBoost were added after the first run. “Out-of-sample” means out of each fold's fitting window, not a clean final holdout. IC values are descriptive means, not claims of statistical significance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fus12mp69cc6w9i8lvyg4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fus12mp69cc6w9i8lvyg4.png" alt="Mean IC versus modeled net per trade for the seven candidates" width="800" height="523"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Net is weighted by the number of trades. Averaging the six fold-level means instead gives LightGBM −3.09 bps: another valid summary, but a different weighting. Neither measure is portfolio return.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5427538h5vzfxe44imj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5427538h5vzfxe44imj.png" alt="Block bootstrap intervals for seven models" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Intervals use 2,000 resamples of 10-calendar-day blocks. All trades within a sampled day stay together; the same days are sampled for each model. Fitted models and selected trades stay fixed during resampling; the models are not refitted. The intervals do not account for model selection or multiple comparisons. The XGBoost and LightGBM intervals crossing zero neither prove profit nor rule it out. The 0.20 bps difference between their point estimates is not a basis for declaring a winner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look inside the average
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40gho29r59c7qekzpgmd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40gho29r59c7qekzpgmd.png" alt="Mean net bps per trade in each of six half-year folds" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;LightGBM's fold means range from −12.49 to +4.29 bps. Its three positive half-years are small beside its two largest negative ones. All seven configurations lose in 2022 H2 under these assumptions. This is regime sensitivity, not a clean prediction of what happens next.&lt;/p&gt;

&lt;p&gt;The day-block bootstrap gives an exploratory LightGBM-minus-OLS interval of [+3.71, +12.90] bps per trade. The models select different trades, the research is post-selection, and this interval is not adjusted for testing multiple candidates. It is not proof that LightGBM generally beats OLS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when positions share capital?
&lt;/h2&gt;

&lt;p&gt;The table weights each trade equally. A model that selects many simultaneous markets may look different when those positions have to share capital. In a separate post-hoc portfolio proxy, we divide 1× gross notional equally among selected symbols on each active bar. The proxy enters at the next open, exits at the following close and pays the same modeled round-trip costs. It holds cash when there is no valid selected signal.&lt;/p&gt;

&lt;p&gt;With the original in-sample threshold, the mean daily additive proxy is −53.68 bps for LightGBM and −54.62 bps for XGBoost. LightGBM has the less negative number under this aggregation, reversing their per-trade ordering. The paired 10-day block-bootstrap interval for XGBoost minus LightGBM is [−14.95, +11.59] bps per day: it includes zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the threshold matter?
&lt;/h2&gt;

&lt;p&gt;The original 99th percentile comes from predictions on the same observations used to fit each model. For a sensitivity check, we fit another model on the first 80% of each outer training window, purge labels crossing the boundary, and calculate the percentile on the remaining 20%. We then refit on the full outer training window before evaluating the next fold. Refitting can change score scale; this is one post-hoc chronological split, not a tuned or independently validated rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth2snj6px3h75rnvzm4a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth2snj6px3h75rnvzm4a.png" alt="Mean daily additive portfolio proxy under two threshold methods for seven models" width="800" height="523"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the training-holdout threshold, the mean daily additive proxy changes to −80.98 bps for LightGBM and −79.67 bps for XGBoost. Their ordering switches again. The paired interval for XGBoost minus LightGBM is [−12.15, +13.46] bps per day, again including zero. Both intervals use 2,000 resamples and common 10-day blocks. Models are not refitted, the historical period was already examined, and the intervals are not adjusted for multiple comparisons.&lt;/p&gt;

&lt;p&gt;These numbers sum bar-level bps by next-bar entry day and divide by all calendar days, including days in cash. Notional resets to 1× whenever a signal is active; returns are not compounded. The proxy omits funding, impact, order-book depth, margin, liquidation and changing equity. Missing or invalid symbol-bars are treated as unavailable signals, and equal gross notional is not equal risk. This is not an executable portfolio backtest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost cliff
&lt;/h2&gt;

&lt;p&gt;For the original five candidates, I held signals fixed and scaled the assumed fee plus slippage:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0kcuz6kohc6eenvr6lmj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0kcuz6kohc6eenvr6lmj.png" alt="Sensitivity to assumed transaction costs for the original five candidates" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;LightGBM's selected trades averaged &lt;strong&gt;+16.15 bps gross proxy&lt;/strong&gt; and &lt;strong&gt;18.08 bps modeled cost&lt;/strong&gt;. At half the assumed cost, net is +7.11 bps; at baseline, −1.93; at twice the cost, −20.01. The break-even multiplier is about &lt;code&gt;16.15 / 18.08 = 0.89&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That number is an arithmetic result for this historical trade set; it does not establish an obtainable fee quote. The sensitivity scales both fees and slippage, keeps the trades fixed, and does not model queueing, market impact or latency. XGBoost and CatBoost are not included in this cost chart.&lt;/p&gt;

&lt;p&gt;To put the units in context: on a hypothetical $10,000 position, one basis point is $1. The LightGBM averages correspond to roughly $16.15 gross proxy, $18.08 assumed costs and −$1.93 net per selected trade. These are scaled averages, not cash flows from a funded account; actual position sizes, simultaneous trades and fills are not modeled in that calculation.&lt;/p&gt;

&lt;p&gt;With 5- and 20-day bootstrap blocks, LightGBM's intervals still cross zero: approximately [−7.86, +3.83] and [−7.88, +3.80] bps. Other dependence assumptions could produce different intervals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs that changed the experiment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The train/test boundary.&lt;/strong&gt; The first script selected training rows by timestamp but constructed their targets using the next row. A final training row could therefore use a price from the evaluation period. The corrected split requires each training label to finish by the evaluation boundary. A regression test changes future prices and verifies that training data does not change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Bar timestamps denote bar opens; label_end is the next bar's close.
&lt;/span&gt;&lt;span class="n"&gt;train&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;train_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label_end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;eval_start&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;eval_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;eval_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label_end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;eval_end&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the end of the label with the evaluation boundary, not just the timestamp on the feature row. This rule matches this next-bar target; longer holding periods or overlapping labels need their own purge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing bars.&lt;/strong&gt; A row shift is not necessarily a 15-minute shift. The corrected run requires 20 consecutive historical intervals and one consecutive future interval. Across loaded history it detected 1,631 gaps and excluded 34,587 rows, including warm-up and incomplete outcomes. The downloadable result lists exclusions and full-file hashes by symbol.&lt;/p&gt;

&lt;p&gt;The old +5.45 bps OLS estimate and this rerun changed both the threshold and cost convention. We cannot attribute their difference to costs alone. We can say that the earlier positive result did not carry over to the new decision and cost contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would change my mind?
&lt;/h2&gt;

&lt;p&gt;An executable-price simulation with point-in-time universe rules, realistic order sizing and a portfolio risk model could change this assessment. So could a genuinely new period.&lt;/p&gt;

&lt;p&gt;If you are building a similar comparison, keep three questions separate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can the score rank returns?&lt;/strong&gt; Compare models on the same untouched time windows, and state whether the IC is pooled or cross-sectional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the decision rule survive costs?&lt;/strong&gt; Freeze the threshold, timing, sizing and fee assumptions; report gross and net results together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Would the portfolio have been executable?&lt;/strong&gt; Check simultaneous exposure, missing data, available liquidity and actual decision-to-order timing. An OHLC open is not a fill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This experiment answers the first two only under its stated historical assumptions. It does not establish the third.&lt;/p&gt;

&lt;p&gt;We have frozen seven fitted candidates for a prospective shadow period from 1 November 2026 to 1 February 2027 UTC, with LightGBM versus OLS as the primary comparison. As of 30 October 2026, that period had not started. It uses the same exchange and local infrastructure, so it is independent &lt;strong&gt;in time&lt;/strong&gt;, not an external audit. A separate observer will record post-decision bid/ask references with at least 500 ms delay and mark missed windows; the quotes are not guaranteed fills.&lt;/p&gt;

&lt;p&gt;Before the prospective test, we checked public Bybit top-of-book snapshots during a short window on 29 October 2026, 14:30–18:00 UTC. The collector recorded 224 confirmed symbol-bars across 16 markets; 222 had a retained bid/ask snapshot. Median quoted spread across the snapshots was 1.06 bps. On our host clock, the median confirmed-candle receipt was 724 ms after the scheduled boundary. This is a small pre-forward diagnostic: quotes can precede a decision, local clock error affects the delay, and no order was sent. It does not measure actual execution. &lt;a href="https://www.pkazantsev.com/writing/lgbm-vs-linear-crypto/pre-forward-quote-diagnostic.json" rel="noopener noreferrer"&gt;Diagnostic summary&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Separately, between 18:57 and 18:59 UTC on 29 October, we submitted five 0.001 BTCUSDT market-order round trips to Bybit's &lt;strong&gt;demo&lt;/strong&gt; environment. In the first, simulated fills were 80,851.8 USDT on entry and 80,845.9 on exit; fees totalled 0.08893374 USDT. All five closed with a loss of between 0.08730960 and 0.10012708 USDT including fees and the price move while holding each position. The &lt;a href="https://www.pkazantsev.com/writing/lgbm-vs-linear-crypto/demo-execution-series.json" rel="noopener noreferrer"&gt;anonymized records&lt;/a&gt; include pre-entry quotes and execution timestamps. This short, one-symbol demo sample contains no model signals: it is not a representative cost estimate, evidence of live fills, or validation of the historical backtest.&lt;/p&gt;

&lt;p&gt;A separate demo-only pilot is configured to attempt at most one 0.001 BTCUSDT round trip per primary model on eligible forward signals, sequentially on a shared account. It can stop for manual review, and connectivity failures can leave a demo position open. Signals can be missed, and the two trades are neither paired nor a statistically useful comparison. Demo fills are simulated; they cannot establish live execution. The pilot does not change the primary shadow protocol.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.pkazantsev.com/writing/lgbm-vs-linear-crypto/historical-corrected-bundle.zip" rel="noopener noreferrer"&gt;Download the research code and results&lt;/a&gt; · &lt;a href="https://www.pkazantsev.com/writing/lgbm-vs-linear-crypto/reproduce.txt" rel="noopener noreferrer"&gt;Reproduction instructions&lt;/a&gt;. We reran all seven models from the extracted bundle using the original files and reproduced the reported aggregates. The bundle includes a public-API downloader and a semantic fingerprint of the original candle slice, including missing timestamps. We separately verified all 140,255 BTC rows and 1 January 2024 for all 16 markets (96 bars each); the other 15 markets have not yet been independently downloaded in full. The downloader stops if the data available from Bybit do not match.&lt;/p&gt;

&lt;p&gt;References: &lt;a href="https://proceedings.neurips.cc/paper_files/paper/2017/hash/6449f44a102fde848669bdd9eb6b76fa-Abstract.html" rel="noopener noreferrer"&gt;LightGBM paper&lt;/a&gt;, &lt;a href="https://arxiv.org/abs/1603.02754" rel="noopener noreferrer"&gt;XGBoost paper&lt;/a&gt;, &lt;a href="https://proceedings.neurips.cc/paper_files/paper/2018/hash/14491b756b3a51daac41c24863285549-Abstract.html" rel="noopener noreferrer"&gt;CatBoost paper&lt;/a&gt;, and &lt;a href="https://scikit-learn.org/stable/auto_examples/applications/plot_cyclical_feature_engineering.html" rel="noopener noreferrer"&gt;scikit-learn's time-aware cross-validation example&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>crypto</category>
      <category>datascience</category>
    </item>
    <item>
      <title>My test suite had 216 passing tests. It was lying to me.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:58:44 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/216-tests-passed-my-python-validator-still-returned-a-false-pass-3j1a</link>
      <guid>https://dev.to/pavel_kkkkazantsev/216-tests-passed-my-python-validator-still-returned-a-false-pass-3j1a</guid>
      <description>&lt;p&gt;An audit found a nested JSON false PASS in a replay-versus-live trace comparator. The local fix is recursive and type-aware; CSV equality remains deliberately unverified without provenance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[1]&lt;/code&gt; and &lt;code&gt;[true]&lt;/code&gt; are different JSON values. My Python comparator said they were equal.&lt;/p&gt;

&lt;p&gt;It returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PASS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deterministic_parity_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That result came from a tool I built to compare a historical replay with a live-style decision pipeline. The point was to find the first recorded difference in features, model scores, decisions, configuration, or input evidence. A strict &lt;code&gt;PASS&lt;/code&gt; was supposed to be the strongest possible result.&lt;/p&gt;

&lt;p&gt;It was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false PASS, reduced to five lines
&lt;/h2&gt;

&lt;p&gt;The audit input was deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both values were valid JSON-like input. Both traces had matching metadata and input hashes. The public recorder wrote them, the parser read them, and the old CLI certified them.&lt;/p&gt;

&lt;p&gt;The underlying Python behavior is legal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;           &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My comparator was asking the wrong question. Scalars went through type-aware logic, but nested lists and dictionaries eventually reached ordinary Python equality. For application code, that can be convenient. For a parity checker, it erases a recorded type difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after: the same trace pair
&lt;/h2&gt;

&lt;p&gt;I reran the preserved pre-fix source snapshot against this trace shape. It returned a verified strict &lt;code&gt;PASS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PASS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deterministic_parity_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public synthetic fixture now runs through the current CLI. This is the actual output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ hfm-parity diff --baseline examples/nested_values/baseline.jsonl \
    --candidate examples/nested_values/candidate.jsonl \
    --output parity-demo.html --json-output parity-demo.json
FAIL: mode=strict; not-verified; aligned=1; findings=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON report identifies the leaf, its classification, and the recorded values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deterministic_parity_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"first_divergence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"features"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payload[0]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"classification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FEATURE_TYPE_MISMATCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"baseline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"candidate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixture, source code, and regression tests are public in &lt;a href="https://github.com/icesurf666/hfm-parity-lab/tree/main/examples/nested_values" rel="noopener noreferrer"&gt;HFM Parity Lab&lt;/a&gt;. It is synthetic by design: it proves a regression boundary, not a historical market measurement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0g0xbsopy0ivqeykcf8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0g0xbsopy0ivqeykcf8.png" alt="Before and after the nested type-mismatch fix" width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The pre-fix &lt;code&gt;PASS&lt;/code&gt; was reproduced from the preserved source snapshot; the current &lt;code&gt;FAIL&lt;/code&gt; is generated by the public synthetic fixture. The fixture demonstrates a regression boundary, not a historical market measurement.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 216 tests did not catch it
&lt;/h2&gt;

&lt;p&gt;The candidate had 216 Python tests when the second audit supplied the nested counterexample. The scalar integer defect was fixed. Clean wheel and source installs worked. The CLI, MCP demo, Ruff, mypy, and frontend build passed.&lt;/p&gt;

&lt;p&gt;The gap was structural: the tests covered scalar numeric semantics, but not the same semantics at every JSON leaf. More happy-path tests would not have made that contract explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tool is intended to compare
&lt;/h2&gt;

&lt;p&gt;Backtests and live pipelines often disagree for reasons that are annoyingly ordinary. A feature was defined differently. A model version changed. A timestamp uses a different clock. An event came from a different source. The final BUY/HOLD decision is usually too late in the chain to explain much.&lt;/p&gt;

&lt;p&gt;I built a trace comparator called HFM Parity Lab around a narrower question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given two recorded executions, what is the first observable difference, and do we have enough evidence to say the executions are comparable?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each JSONL trace records run metadata plus event-level fields: an event ID, stage, features, timestamps, input hash, model score, decision, and optional state hash. The engine aligns &lt;code&gt;event.id + stage&lt;/code&gt;, then returns &lt;code&gt;PASS&lt;/code&gt;, &lt;code&gt;FAIL&lt;/code&gt;, &lt;code&gt;NOT_COMPARABLE&lt;/code&gt;, or &lt;code&gt;UNVERIFIABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Strict mode is for two runs that claim to reproduce the same thing. Regression mode is for deliberate old-versus-new comparisons. Regression mode can report agreement, but it never certifies strict parity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first audit found other failures too
&lt;/h2&gt;

&lt;p&gt;The first independent audit began with 87 passing tests and found nine problems. These three are the easiest to reproduce.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Large integers were converted to floats
&lt;/h3&gt;

&lt;p&gt;The first comparison helper turned numbers into &lt;code&gt;float&lt;/code&gt; values before comparison.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="mi"&gt;9007199254740993&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;9007199254740992&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Past &lt;code&gt;2**53&lt;/code&gt;, IEEE-754 doubles cannot represent every integer. The initial engine could call those values equal and return a verified strict &lt;code&gt;PASS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix compares integers exactly. An integer and a float are a type mismatch even when their values look equal; tolerance applies only when both values are floats. The repaired scalar tests cover values far beyond the floating-point range.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Matching event IDs could hide a different market
&lt;/h3&gt;

&lt;p&gt;Two traces might both include &lt;code&gt;evt-17&lt;/code&gt;, while one came from BTC and the other from ETH. The initial strict comparison did not treat &lt;code&gt;symbol&lt;/code&gt;, &lt;code&gt;venue&lt;/code&gt;, or &lt;code&gt;data_source&lt;/code&gt; as part of the identity contract.&lt;/p&gt;

&lt;p&gt;That makes event alignment look more meaningful than it is.&lt;/p&gt;

&lt;p&gt;Strict mode now returns &lt;code&gt;NOT_COMPARABLE&lt;/code&gt; for declared identity mismatches. It also needs a data source, input hashes on aligned events, an actual output to compare, and a model version when model scores exist. Missing evidence returns &lt;code&gt;UNVERIFIABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is a practical difference between "I did not see a mismatch" and "the traces contain enough evidence to verify parity." A useful tool needs both answers.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The MCP output limit was per value, not per response
&lt;/h3&gt;

&lt;p&gt;The first MCP formatter shortened nested values one at a time, then built the response. That did not constrain the total payload. The remediation report recorded a roughly 10 MB input yielding about 17.7 MB from one comparison call.&lt;/p&gt;

&lt;p&gt;For a local agent tool, that is enough to flood a context window with repeated evidence.&lt;/p&gt;

&lt;p&gt;The current implementation limits the whole response, preserves totals, labels truncation, and drops detail in a deterministic order. Its tests measure the actual structured plus text payload received by an MCP client as well as the formatting helper.&lt;/p&gt;

&lt;p&gt;The audit also found recorder/parser disagreement, event reordering that could pass, missing provenance that looked like agreement, and a report viewer that could not open the external JSON it documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  The P0 repair
&lt;/h2&gt;

&lt;p&gt;The fix walks JSON-like values recursively and keeps the type explicit at every path: null, boolean, integer, float, string, array, object, or missing.&lt;/p&gt;

&lt;p&gt;That makes the rules identical at the root and inside a container:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[1]&lt;/code&gt; versus &lt;code&gt;[true]&lt;/code&gt; is a mismatch;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;{ "x": 1 }&lt;/code&gt; versus &lt;code&gt;{ "x": 1.0 }&lt;/code&gt; is a mismatch;&lt;/li&gt;
&lt;li&gt;null and a missing key are distinct;&lt;/li&gt;
&lt;li&gt;tolerance applies only to finite float-versus-float values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In compact form, the contract is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;same(path) = same_json_type(a, b) ∧ compare_by_type(a, b)

integers: exact
floats:   finite(a, b) ∧ |a - b| ≤ ε
arrays:   same length ∧ same(index) for every index
objects:  same keys ∧ same(key) for every key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwhcoqw8nqcmwfps8sx1h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwhcoqw8nqcmwfps8sx1h.png" alt="Recursive type-aware parity contract" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The comparison checks type before value at every nested path. Float tolerance applies only when both recorded values are floats.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is a specification. Its purpose is to make the important bit reviewable: a convenient host-language equality operator never gets to silently redefine the verification contract.&lt;/p&gt;

&lt;p&gt;The regression coverage takes those cases through the public recorder, JSONL reader, CLI, and a real stdio MCP process. The local suite is now 242 Python tests; Ruff, strict mypy, package builds, clean-wheel installation, frontend tests, and the frontend build also pass.&lt;/p&gt;

&lt;p&gt;That is a precise claim about the known failure: the local release candidate no longer certifies those nested type differences as strict parity.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSV onboarding and verdict strength
&lt;/h2&gt;

&lt;p&gt;The same candidate adds a small CSV Quick Compare path. A developer maps identity columns, stages, features, types, and units from two CSV logs; the existing trace engine then aligns and compares them. It does not import raw exchange data or introduce a second diff engine.&lt;/p&gt;

&lt;p&gt;The important limitation is deliberate: equal CSV rows normally cannot establish matching code, configuration, model, input snapshot, or clock domain. An equal comparison therefore stays &lt;code&gt;UNVERIFIABLE&lt;/code&gt; unless that provenance is supplied. A mismatch remains useful evidence of a difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffubv749zezi2j9zt9jxo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffubv749zezi2j9zt9jxo.png" alt="Verdict ladder distinguishing a mismatch from missing evidence and verified strict parity" width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A useful tool does not turn absent evidence into agreement. It says what the supplied records can and cannot support.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A test technique worth reusing
&lt;/h2&gt;

&lt;p&gt;For any validator that can return a strong success result, write inputs that contradict the result by construction. For a JSON comparator, that means testing each rule at the root and inside objects and arrays:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;True&lt;/code&gt; versus &lt;code&gt;1&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; versus &lt;code&gt;1.0&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a missing key versus &lt;code&gt;null&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a value just inside and just outside float tolerance;&lt;/li&gt;
&lt;li&gt;identical event IDs with different declared provenance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the guarantee concrete enough to attack. The repository is public; the reported 242 local tests and quality gates are useful evidence, not an independent correctness proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The repository includes only synthetic examples; it does not contain HFM market data or a trading system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/icesurf666/hfm-parity-lab.git
&lt;span class="nb"&gt;cd &lt;/span&gt;hfm-parity-lab &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; hfm-parity demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes a synthetic HTML and JSON report. The &lt;a href="https://github.com/icesurf666/hfm-parity-lab/blob/main/tests/test_nested_values.py" rel="noopener noreferrer"&gt;nested-value regression tests&lt;/a&gt; cover the false-PASS class described here; the &lt;a href="https://github.com/icesurf666/hfm-parity-lab#readme" rel="noopener noreferrer"&gt;README&lt;/a&gt; has the trace format and CSV workflow.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>debugging</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I Let an LLM Agent Search for Trading Strategies. A Simple Heuristic Beat It.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Tue, 20 Oct 2026 18:22:17 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/i-let-an-llm-agent-search-for-trading-strategies-a-simple-heuristic-beat-it-10k2</link>
      <guid>https://dev.to/pavel_kkkkazantsev/i-let-an-llm-agent-search-for-trading-strategies-a-simple-heuristic-beat-it-10k2</guid>
      <description>&lt;p&gt;I gave DeepSeek V3 forty chances to find a profitable trading strategy.&lt;/p&gt;

&lt;p&gt;A simple heuristic found +52% net. The LLM's early proposals lost money. After fixing three silent bugs the agent surfaced, it improved — then optimized its way to +123.7%, a number that didn't survive scrutiny. Here's what happened across four rounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;In earlier posts in this series I tested &lt;a href="https://dev.to/pavel_kkkkazantsev/my-crypto-backtest-had-a-sharpe-of-134-then-i-added-trading-costs-5718"&gt;six dead-simple strategies&lt;/a&gt; and &lt;a href="https://dev.to/pavel_kkkkazantsev/three-crypto-edges-three-different-killers-1i6o"&gt;three more sophisticated edges&lt;/a&gt; — all killed by fees, adverse selection, or data leakage. What survived was a backtest harness: point-in-time universe, walk-forward, trading costs, out-of-sample scoring only. Same rules for every strategy, no exceptions. I put an agent in front of it.&lt;/p&gt;

&lt;p&gt;A proposal is a typed JSON object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;        &lt;span class="c1"&gt;# "reversal" | "factor_residual" | "momentum"
&lt;/span&gt;    &lt;span class="n"&gt;bar_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;      &lt;span class="c1"&gt;# "15m" | "1h" | "1d"
&lt;/span&gt;    &lt;span class="n"&gt;lookback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;      &lt;span class="c1"&gt;# days
&lt;/span&gt;    &lt;span class="n"&gt;holding_period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;entry_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;

&lt;span class="c1"&gt;# The agent loop
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;agent_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_rounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScoredProposal&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScoredProposal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_rounds&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# what failed, what survived
&lt;/span&gt;        &lt;span class="n"&gt;json_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# DeepSeek V3 via OpenRouter
&lt;/span&gt;        &lt;span class="n"&gt;proposal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_proposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# walk-forward over 451 symbols, 76 windows
&lt;/span&gt;        &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ScoredProposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent never touches the scoring. It proposes; the same judge that every hand-built strategy went through decides. There is no way for it to talk its way to a good number.&lt;/p&gt;

&lt;p&gt;I also kept a heuristic proposer alongside it: samples the space randomly, occasionally perturbs the current best. It is not purely random — the perturbation step is local optimization — but it needs no model and costs nothing. Both the heuristic and the agent ran for the same 40 evaluation rounds. That baseline turned out to matter a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round one: the model lost to the heuristic
&lt;/h2&gt;

&lt;p&gt;The first LLM run was worse than the heuristic search.&lt;/p&gt;

&lt;p&gt;DeepSeek had the right instinct — it leaned toward factor-residual reversion on daily bars (buy coins whose recent return lagged the cross-sectional average, hold for ~30 days), which was the only corner that remained promising in this run. But it picked short lookbacks and fast rebalances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;factor_residual/1d/lb30/reb15   -23.7%   reject
factor_residual/1d/lb20/reb10   -44.4%   reject
reversal/15m/lb5/reb2           -99.1%   reject
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile the heuristic had stumbled into &lt;code&gt;factor_residual/1d/lb60/reb30&lt;/code&gt;: about +52% net, positive in most out-of-sample windows, tiny turnover. The model landed one step away from the edge and picked the wrong side of it. Right family, wrong calibration. The heuristic beat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs the harness caught
&lt;/h2&gt;

&lt;p&gt;Before I wrote the model off, something more useful turned up.&lt;/p&gt;

&lt;p&gt;Several proposals came back with zero turnover and zero return. Not negative — flat. The strategy had never opened a position. The three bugs had the same symptom: a flat book, zero turnover, no crash, and no error message. Just silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: Momentum's volatility window was scaled wrong.&lt;/strong&gt;&lt;br&gt;
The signal normalized by a 96-bar trailing volatility. On 15-minute data that is one day — fine. On daily bars it is 96 days, longer than the formation window, so the signal never became valid.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Bug: hardcoded window ignores bar size
&lt;/span&gt;&lt;span class="n"&gt;vol_window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;

&lt;span class="c1"&gt;# Fix: scale to timeframe; floor of 5 bars avoids a 1-bar window on daily
&lt;/span&gt;&lt;span class="n"&gt;BAR_MINUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;vol_window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;BAR_MINUTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;bar_size&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="c1"&gt;# 15m → 96 bars (one day)  |  1h → 24 bars  |  1d → max(5,1) = 5 days
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 2: Lookback could exceed the formation window.&lt;/strong&gt;&lt;br&gt;
The agent proposed a 100-day lookback on a 90-day formation window. Fix: clamp the lookback to what is actually available.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lookback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lookback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;formation_window&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 3: The rebalance grid starved itself.&lt;/strong&gt;&lt;br&gt;
The wrapper refreshed positions on a fixed grid from bar zero: bar 0, bar 40, bar 80. If the signal only came alive around bar 90, every refresh point landed in the warmup period. A holding period of 40 was dead while 30 and 45 traded — non-monotonic results that mean "bug", not "no edge".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Bug: grid anchored at bar 0
&lt;/span&gt;&lt;span class="n"&gt;rebalance_bars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_bars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holding_period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Fix: anchor at first live bar
&lt;/span&gt;&lt;span class="n"&gt;first_live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;warmup_length&lt;/span&gt;
&lt;span class="n"&gt;rebalance_bars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_bars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holding_period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these were crashes. Each returned a flat book with no error. That is exactly why a harness should fail loudly when a strategy that is meant to trade has zero turnover. Treating "no position" as a valid zero-return result would have buried all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: it maps the edge
&lt;/h2&gt;

&lt;p&gt;With the space cleaned up, I reran the LLM agent. This time it clustered on factor-residual daily and swept the lookback itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;factor_residual/1d/lb70/reb35   +67.1%
factor_residual/1d/lb50/reb25   +65.9%
factor_residual/1d/lb40/reb20    +6.5%
factor_residual/1d/lb30/reb15   -26.6%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fme6xf216cwfighwfb1xn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fme6xf216cwfighwfb1xn.png" alt="Edge map: lookback vs net return"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model drew the line itself. Long lookback positive, short lookback negative, and it found the boundary from feedback alone — no knowledge of what the heuristic had found. Right family, right direction, just needed a clean space to work in.&lt;/p&gt;

&lt;p&gt;So is the edge real? Not established.&lt;/p&gt;

&lt;p&gt;Across lookbacks from 40 to 80 days with monthly rebalance it stays net positive, roughly +46% to +84% over the backtest period. (The edge map above shows lb40 at only +6.5% because that sweep used a faster reb20 rebalance — different configuration, same lookback.) Positive in four of five years, down hard in 2023. Survives a pessimistic 30 bps per side. Annualized Sharpe around 0.9. A label-permutation null — which scrambles which coin gets which cross-sectional residual, testing whether the ranking actually carries signal — puts it at p = 0.048, right at the edge of significant.&lt;/p&gt;

&lt;p&gt;A promising but unconfirmed lead. The parameters and family were selected on these same results, so the p-value doesn't account for the full search process. Not a confirmed strategy — the backtest window is used up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round three: a better optimizer, a bigger number
&lt;/h2&gt;

&lt;p&gt;I wrapped the model in a hybrid proposer — the LLM picks the family and timeframe, but on alternating turns it sweeps the lookback and rebalance around the current best.&lt;/p&gt;

&lt;p&gt;It found a peak at a 50-day lookback with monthly rebalance: &lt;strong&gt;+123.7% net&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;(Round 2 showed lb50 at +65.9% with reb25. Same lookback, different rebalance, and now optimizing both jointly — that's where the extra 60 points came from. Also the best of 35 configs, not the average.)&lt;/p&gt;

&lt;p&gt;This is a trap, and one column gives it away:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Round 3 (argmax)&lt;/th&gt;
&lt;th&gt;Round 4 (ensemble)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Net return&lt;/td&gt;
&lt;td&gt;+123.7%&lt;/td&gt;
&lt;td&gt;+75.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% positive OOS windows&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bootstrap worst case&lt;/td&gt;
&lt;td&gt;−55%&lt;/td&gt;
&lt;td&gt;−9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bootstrap positive (20 runs)*&lt;/td&gt;
&lt;td&gt;16/20&lt;/td&gt;
&lt;td&gt;18/20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6488cstmu72j6z6cdaie.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6488cstmu72j6z6cdaie.png" alt="Rounds comparison"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;* 20 bootstrap runs is preliminary; treat the positive-run counts as directional, not precise.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Magnitude doubled. Breadth barely moved.&lt;/p&gt;

&lt;p&gt;You can inflate magnitude by picking better parameters. Breadth doesn't work that way. It didn't move.&lt;/p&gt;

&lt;p&gt;Here's the problem with reporting the best of 35 configs: even if none of them had any real edge, random variation across 35 tries would still produce one winner. A deflated Sharpe helps, but only for the search you tell it about. The reported 0.70 used this local 7×5 Round 3 grid: N = 35. It did not include the earlier LLM rounds, the heuristic search, the family/timeframe choice, or the debugging runs that led to the grid. Nor are these 35 independent tests: nearby lookbacks produce highly correlated variants of the same signal.&lt;/p&gt;

&lt;p&gt;So 0.70 is not a fully search-corrected significance claim. It is a local-grid diagnostic — and even under that friendlier accounting it missed the project's 0.95 qualification threshold. The honest conclusion is simpler: the best configuration did not establish significance. A broader accounting of the search would make the evidence weaker, not stronger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round four: I hadn't used my own tools
&lt;/h2&gt;

&lt;p&gt;Overfitting has known cures. I hadn't reached for any of them.&lt;/p&gt;

&lt;p&gt;Stop picking a winner. Average the whole plateau instead.&lt;/p&gt;

&lt;p&gt;I took all factor-residual configs on daily bars with lookback between 40 and 80 — the range where the sign stayed consistent — and averaged them without picking a winner.&lt;/p&gt;

&lt;p&gt;The plateau range itself came from inspecting these same results, so it doesn't fully eliminate selection bias. But it does reduce sensitivity to any single lucky configuration. The +75.8% is less sensitive to one lucky setting, but it is still an in-sample research result, not independent confirmation of alpha.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Plateau: family=factor_residual, bar=1d, lookback in [40..80]
# Range chosen where sign was consistent — but still data-derived, not pre-specified.
&lt;/span&gt;&lt;span class="n"&gt;plateau&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
           &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;factor_residual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
           &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bar_size&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
           &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lookback&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;ensemble_signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;plateau&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I equal-weighted the factor-residual signal across lookbacks from 40 to 80 days, one combined strategy, monthly rebalance.&lt;/p&gt;

&lt;p&gt;Result: +75.8% net, annualized Sharpe 0.61, positive in 62% of windows. That is not a significance claim. A rough calculation using five annual observations puts the t-statistic at about 1.4, before any search correction. The 30-day holds also make adjacent walk-forward windows overlap. So 62% positive windows is a useful breadth check, not 76 independent confirmations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn0pyz1xaspfcczg5qc2o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn0pyz1xaspfcczg5qc2o.png" alt="Bootstrap robustness: argmax vs ensemble"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both configurations target the same hypothesized signal. The ensemble is less exposed to one lucky (lb, reb) combination; that is all the bootstrap difference shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;I did not build an AI that finds alpha.&lt;/p&gt;

&lt;p&gt;I built a cheap agent with the right instinct and the wrong calibration. The harness exposed three silent bugs because a flat book looked flat. The optimizer then used the cleaned-up space to add 60 percentage points to a backtest number. The ensemble made the remaining signal less tied to one parameter choice, but did not turn it into proof.&lt;/p&gt;

&lt;p&gt;Three things I'd take from this:&lt;/p&gt;

&lt;p&gt;Run a heuristic baseline first. Until the search space was clean, it beat the model. Don't skip it just because the LLM reasons better.&lt;/p&gt;

&lt;p&gt;The agent is only as honest as the thing that scores it. The judge comes first. The proposer is easy to swap out.&lt;/p&gt;

&lt;p&gt;Once the proposer can optimize, it will. Watch breadth, not magnitude. And when you can average the plateau instead of picking the best config, do it — the gap between +124% and +76% was entirely parameter sensitivity.&lt;/p&gt;

&lt;p&gt;Give a language model a truthful referee and a constrained enough space, and it can find a corner worth inspecting. It will not necessarily beat a simple heuristic, especially if the search space has silent bugs. Once it can optimize, expect a number larger than the evidence warrants unless you correct for the search.&lt;/p&gt;

&lt;p&gt;The referee is the part that matters.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>datascience</category>
      <category>ai</category>
    </item>
    <item>
      <title>14,399 Backtest Events Gave Me t = 8.20. Equal-Weighting Days Gave 1.66</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Fri, 16 Oct 2026 16:40:42 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/14399-backtest-events-gave-me-t-820-equal-weighting-days-gave-166-2hg7</link>
      <guid>https://dev.to/pavel_kkkkazantsev/14399-backtest-events-gave-me-t-820-equal-weighting-days-gave-166-2hg7</guid>
      <description>&lt;p&gt;A crypto-perpetual breakout simulation produced 14,399 events across 15 symbols from September 2025 to September 2026. Mean net return was &lt;strong&gt;+25.98 bps&lt;/strong&gt; per event, with a naive event-level t-statistic of &lt;strong&gt;+8.20&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But the result depended heavily on how observations were weighted. Returns were concentrated in a small number of market sessions, something the aggregate statistic did not reveal.&lt;/p&gt;

&lt;p&gt;Pooled over events, the mean is positive. Per day, it is negative. The gap is entirely Oct 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same events, different unit of analysis
&lt;/h2&gt;

&lt;p&gt;I grouped events by calendar day, computed one mean event return per day, then ran a one-sample t-test on those 365 daily means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event-level:  t = mean(event returns) / (sd / √N)
               = +25.98 / (380.2 / √14,399)  =  +8.20

day-level:    t = mean(daily means)   / (sd / √N)
               = −4.67  / (53.82 / √365)     =  −1.66
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are different estimands. The first weights every event equally; the second weights every trading day equally. The sign reversal does not prove the naive SE was wrong — it shows that the result is sensitive to the weighting choice, which is reason enough to report both. The second is a diagnostic, not a replacement for clustered standard errors or a block bootstrap. For any strategy that fires multiple times a day on correlated moves, it's a useful sanity check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day behind the result
&lt;/h2&gt;

&lt;p&gt;October 10, 2025 accounted for most of the positive event-level return. DOTUSDT traded between $0.94 and $4.29 during a market-wide flash crash. ATOMUSDT also experienced extreme price dislocations. Whether the simulated trades could have been executed at those prices is unclear. The simulation's 434 events that day averaged &lt;strong&gt;+981 bps&lt;/strong&gt;. Remove that one day and the event-level mean changes from +25.98 to &lt;strong&gt;−3.69 bps&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The median event lost −16.2 bps. Win rate was 34%. The maximum event return was +8,756 bps. The top 1% of events were enough to flip the mean. Only one day made it positive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9zpf75h3g57x6bq9tlp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9zpf75h3g57x6bq9tlp.png" alt="Aggregate results: all events +25.98 bps, without Oct 10 −3.69 bps, Oct 10 alone +981 bps across 434 events" width="799" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;October 10, 2025 contributed 434 of 14,399 events, but those events averaged +981 bps. Removing that one day changes the event-level mean from +25.98 to −3.69 bps.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statistic&lt;/th&gt;
&lt;th&gt;Unit / N&lt;/th&gt;
&lt;th&gt;Mean net&lt;/th&gt;
&lt;th&gt;SD&lt;/th&gt;
&lt;th&gt;t-stat&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Naive t-test&lt;/td&gt;
&lt;td&gt;14,399 events&lt;/td&gt;
&lt;td&gt;+25.98 bps&lt;/td&gt;
&lt;td&gt;380.2 bps&lt;/td&gt;
&lt;td&gt;+8.20&lt;/td&gt;
&lt;td&gt;Assumes independent events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily diagnostic&lt;/td&gt;
&lt;td&gt;365 days&lt;/td&gt;
&lt;td&gt;−4.67 bps&lt;/td&gt;
&lt;td&gt;53.8 bps&lt;/td&gt;
&lt;td&gt;−1.66&lt;/td&gt;
&lt;td&gt;Two-sided p ≈ 0.098&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sensitivity: without Oct 10&lt;/td&gt;
&lt;td&gt;364 days&lt;/td&gt;
&lt;td&gt;−7.37 bps&lt;/td&gt;
&lt;td&gt;14.9 bps&lt;/td&gt;
&lt;td&gt;−9.42&lt;/td&gt;
&lt;td&gt;Post-hoc; exposes concentration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Event-level SD is 380 bps; day-level SD is 54 bps — a 7× collapse. Both t-stats use the same formula (mean / SE), but they're measuring different dispersions. The event-level SE is tiny because N=14,399; the day-level SE is larger because the daily means are themselves variable, and one day (Oct 10, mean +981 bps) inflates both the event-level mean and the day-level SD in opposite directions.&lt;/p&gt;

&lt;p&gt;The sensitivity row is not a confirmatory result. Removing an outlier and calling the rerun clean is p-hacking. It is there to show how much of the claim rests on one day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;day_block_t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returns_bps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Equal-weighted daily diagnostic for event returns.

    Groups events by calendar day, computes the mean return per day,
    and tests whether the distribution of daily means differs from zero.

    This changes the unit of analysis from event to trading day.
    It is NOT a cluster-robust standard error for the event-level mean.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;returns_bps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returns_bps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamps&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_numpy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returns_bps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;returns_bps and timestamps must have equal length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isfinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returns_bps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;returns_bps must be finite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_bps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;returns_bps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;daily&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_bps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;at least two trading days are required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;t_stat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ttest_1samp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;popmean&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily_mean_bps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# input already in bps
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t_stat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t_stat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p_value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# keep the series — inspect it, don't trust the summary
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Usage
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;day_block_t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_returns_bps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_timestamps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Event-level t = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event_returns_bps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_returns_bps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;std&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ddof&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_returns_bps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Day-level   t = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t_stat&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  (N=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Event-level t = +8.20
# Day-level   t = -1.6579  (N=365 days)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the daily series in the result. A t-statistic is one number; the series shows where the result is coming from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspecting the concentration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.pyplot&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;

&lt;span class="n"&gt;daily&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# already in bps
&lt;/span&gt;
&lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subplots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;figsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#ef4444&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#4ade80&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#f87171&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;axhline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#6b7280&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_xlabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Trading day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Daily mean return (bps)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Daily mean return — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days, t = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t_stat&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Flag days &amp;gt; 3σ
&lt;/span&gt;&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;std&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;outliers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;outliers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;annotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_loc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;xytext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;textcoords&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset points&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;ha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#ef4444&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tight_layout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Leave-one-day-out
&lt;/h2&gt;

&lt;p&gt;Removing October 10 explicitly is post-hoc. The full diagnostic runs the same removal for all 365 days and plots t-stat vs dropped day.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykslt2tyw8ix421vza48.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykslt2tyw8ix421vza48.png" alt="Leave-one-day-out t-stat plot. 364 days cluster near −1.5. October 10 is the single outlier: dropping it moves t from −1.66 to −9.42." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;LOO diagnostic across all 365 trading days. Dropping any day other than Oct 10 barely moves the t-stat (−1.3 to −1.8 range). Dropping Oct 10 alone moves it to −9.42. One spike, standing alone — concentration, not fat tails.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If several days had moved the t-stat materially, the process would be fat-tailed and the right test would be a week-block bootstrap. That's not what happened here: one day, one event cluster, and it's gone when you drop Oct 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;There is no single correct return series. If capital is allocated to a portfolio, construct portfolio returns. If the estimand is per-event alpha, preserve that estimand and use an estimator that accounts for dependence: clustered standard errors, a block bootstrap, or HAC/Newey–West where it applies.&lt;/p&gt;

&lt;p&gt;Nor is deleting October 10 a valid way to obtain a clean p-value. The primary result keeps every day. The removal is explicitly a post-hoc sensitivity analysis: it tells us how fragile the claim is, not what a pre-specified experiment would have concluded.&lt;/p&gt;

&lt;p&gt;The daily t-test still assumes independence across days. It does not automatically account for serial correlation, which is another reason it is a diagnostic rather than the final inference procedure.&lt;/p&gt;

&lt;p&gt;The 14,399 events weren't 14,399 independent observations. Most of the positive return came from 434 events on a single day. The naive t-stat didn't reveal that concentration. The daily breakdown did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://www.pkazantsev.com/writing/hfm-research-origin/" rel="noopener noreferrer"&gt;HFM Research methodology series&lt;/a&gt; — six things we found when we audited our own backtest results.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>datascience</category>
    </item>
    <item>
      <title>I Built an AI That Checks Whether Digital Products Deliver What They Promise</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Tue, 13 Oct 2026 20:17:51 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/i-built-an-ai-that-checks-whether-digital-products-deliver-what-they-promise-4nf2</link>
      <guid>https://dev.to/pavel_kkkkazantsev/i-built-an-ai-that-checks-whether-digital-products-deliver-what-they-promise-4nf2</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  I Built an AI That Checks Whether Digital Products Deliver What They Promise
&lt;/h1&gt;

&lt;p&gt;A digital-product listing can promise “48 pages”, “A4 and US Letter editions”, and “debt repayment worksheets.” Then the customer opens the ZIP and finds something else.&lt;/p&gt;

&lt;p&gt;For a small seller, that turns into refunds, support messages, rough reviews, and the awful feeling that you missed something obvious. PromiseDiff is a local-first pre-publish check for Etsy, Gumroad, template, and educational-PDF sellers.&lt;/p&gt;

&lt;p&gt;Paste in the listing. Upload the actual PDF or ZIP. PromiseDiff shows what the files can back up, what they contradict, and what they simply cannot prove.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule I cared about most:&lt;/strong&gt; missing text is not a failure. A measured mismatch is a contradiction; a claim the files cannot prove is just unverified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flpe6rlvasqozstimn3g8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flpe6rlvasqozstimn3g8.png" alt="PromiseDiff checks listing promises against delivered files" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Every claim gets one of four plain outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SUPPORTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The supplied evidence supports this exact promise.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONTRADICTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A reproducible check established a conflicting fact.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNVERIFIED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The files do not establish the claim. Missing evidence is not a contradiction.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OUT_OF_SCOPE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The claim needs a capability the MVP cannot test.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every result keeps the method and the evidence that led to it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuianwxb6jpbm4owiwpkg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuianwxb6jpbm4owiwpkg.png" alt="Four verdicts distinguish evidence from uncertainty" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For example, a page-count claim is deterministic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Listing: “The main PDF contains exactly 48 pages.”
File:    budget_planner_A4.pdf → 48 pages
Result:  SUPPORTED — DETERMINISTIC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;“Includes debt repayment worksheets” is different. A PDF might call that page a “Loan Balance Reduction Tracker.” Here Gemma looks at the extracted text, but PromiseDiff only accepts the result after checking the suggested filename, page, and quote against the actual PDF.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzyoyehxy75kli9qewz5k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzyoyehxy75kli9qewz5k.png" alt="The verification flow combines direct measurement with semantic evidence" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The demo uses a real 48-page A4 Budget Planner PDF. Nothing is mocked.&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://github.com/icesurf666/progress-cut" rel="noopener noreferrer"&gt;ProgressCut&lt;/a&gt; to cut the screen recording down to a short walkthrough.&lt;/p&gt;

&lt;p&gt;The listing promises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The main PDF contains exactly 48 pages.
Includes both A4 and US Letter PDF editions.
Contains monthly budget review worksheets.
Includes savings goal trackers.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload only has the A4 edition. PromiseDiff confirms the page count, flags the missing US Letter version, and lets the seller open the evidence or export the report as Markdown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F044a4a0yk9l655phxv23.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F044a4a0yk9l655phxv23.gif" alt="A short PromiseDiff walkthrough, recorded and cut with ProgressCut" width="640" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why a hybrid system instead of “ask the model”?
&lt;/h3&gt;

&lt;p&gt;Some questions have a simple answer; others need interpretation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Correct tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How many PDF pages exist?&lt;/td&gt;
&lt;td&gt;PyMuPDF metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is this page actually A4?&lt;/td&gt;
&lt;td&gt;PDF geometry with a 3-point tolerance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does a listed file exist?&lt;/td&gt;
&lt;td&gt;Complete file inventory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does “Loan Balance Reduction Tracker” plausibly satisfy a debt-repayment claim?&lt;/td&gt;
&lt;td&gt;Gemma plus validated quotation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Asking a language model to count pages is slower and worse than reading the PDF metadata. But keyword search alone will not reliably connect “debt repayment” and “loan balance reduction.” So each tool only gets the jobs it can support with evidence.&lt;/p&gt;

&lt;p&gt;The rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;missing evidence != contradiction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm80696fodwn5xo4zbpfg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm80696fodwn5xo4zbpfg.png" alt="A model citation must pass the evidence gate before it can support a claim" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the document is scanned, the claim scope is ambiguous, a citation points to a nonexistent page, or a quote does not occur in extracted text, the answer is &lt;code&gt;UNVERIFIED&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture and safety
&lt;/h3&gt;

&lt;p&gt;It is a small React + FastAPI app. The verification-flow diagram above shows how the pieces connect.&lt;/p&gt;

&lt;p&gt;Uploads are untrusted, so the backend puts tight boundaries around them: size, PDF count, total pages, extracted text, ZIP ratio, file types, and expanded ZIP size. It rejects traversal paths, duplicate entries, encrypted PDFs, nested archives, and corrupt files. The original upload is never saved.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkzr0pk0535i18umh5dbw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkzr0pk0535i18umh5dbw.png" alt="The upload boundary rejects unsafe archives before analysis" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model work is deliberately kept small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gemma extracts structured promises from the listing.&lt;/li&gt;
&lt;li&gt;Python validates source quotations and normalizes measurable claims.&lt;/li&gt;
&lt;li&gt;Local retrieval selects compact, potentially relevant PDF excerpts.&lt;/li&gt;
&lt;li&gt;Gemma evaluates semantic correspondence in batch.&lt;/li&gt;
&lt;li&gt;Evidence validation checks filename, page, and exact quote before serialization.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why does open innovation matter?
&lt;/h2&gt;

&lt;p&gt;Many sellers are working on unpublished material: course notes, templates, client downloads, and draft listings. With the local setup, Ollama keeps the listing text and extracted PDF text on the seller’s machine.&lt;/p&gt;

&lt;p&gt;Open-weight Gemma also makes this affordable for a small operation. A local run has no per-audit third-party API bill, although someone still pays for the hardware or hosting. Sellers can repeat the same check before every release without handing their draft material to another service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducible benchmark, including a failure
&lt;/h2&gt;

&lt;p&gt;I made ten independently labelled synthetic PDF cases and ran them through the same local API the UI uses. The runner saves the raw responses and timings in &lt;code&gt;benchmark/results.json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exact-label accuracy = correct labels / total cases = 9 / 10 = 90%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawlzkit8rrktrvyvx51b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawlzkit8rrktrvyvx51b.png" alt="Nine benchmark cases matched their expected labels; one paraphrase stayed unverified" width="799" height="413"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;xychart-beta
  title "Exact classification by category (10 synthetic cases)"
  x-axis [Supported, Contradicted, Unverified, OutOfScope]
  y-axis "Correct cases" 0 --&amp;gt; 4
  bar [3, 2, 3, 1]&lt;/code&gt;&lt;/pre&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Caveat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact-label accuracy&lt;/td&gt;
&lt;td&gt;90% (9/10)&lt;/td&gt;
&lt;td&gt;Tiny synthetic evaluation, not a generalization claim.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contradiction precision&lt;/td&gt;
&lt;td&gt;100% (2/2)&lt;/td&gt;
&lt;td&gt;Both measured failures were page/dimension facts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contradiction recall&lt;/td&gt;
&lt;td&gt;100% (2/2)&lt;/td&gt;
&lt;td&gt;Same small set.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supported recall&lt;/td&gt;
&lt;td&gt;75% (3/4)&lt;/td&gt;
&lt;td&gt;One semantic paraphrase was conservatively left unverified.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median API latency&lt;/td&gt;
&lt;td&gt;2.11 s&lt;/td&gt;
&lt;td&gt;Local Gemma on this machine.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mean API latency&lt;/td&gt;
&lt;td&gt;3.50 s&lt;/td&gt;
&lt;td&gt;Prompt-injection case took 13.81 s.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The miss is the useful bit. The listing said &lt;strong&gt;debt repayment worksheet&lt;/strong&gt; and the PDF said &lt;strong&gt;Loan Balance Reduction Tracker&lt;/strong&gt;. The model did not have strong enough evidence to connect them, so PromiseDiff returned &lt;code&gt;UNVERIFIED&lt;/code&gt; instead of pretending it knew. That is a limitation to fix, not a result to hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The benchmark is small and synthetic. It is not a claim about every kind of seller PDF.&lt;/li&gt;
&lt;li&gt;Scanned documents without extractable text can still receive metadata checks, but semantic claims remain unverified.&lt;/li&gt;
&lt;li&gt;The current retrieval can miss paraphrases. In the benchmark, “debt repayment worksheet” and “Loan Balance Reduction Tracker” stayed unverified rather than becoming a false positive.&lt;/li&gt;
&lt;li&gt;Public deployments should isolate PDF processing with strict resource limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would build next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Better paraphrase retrieval before semantic evaluation.&lt;/li&gt;
&lt;li&gt;Seller feedback on which promise types produce the most costly mistakes.&lt;/li&gt;
&lt;li&gt;Batch release audits and version-to-version diffs.&lt;/li&gt;
&lt;li&gt;Isolated PDF processing for public deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/icesurf666/PromiseDiff" rel="noopener noreferrer"&gt;PromiseDiff source code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository has the local setup, real example PDF, test commands, security notes, and benchmark runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Use of Gemma&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Tested 58 Trading Hypotheses. 48 Failed. Here’s the Framework That Killed Them.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Fri, 09 Oct 2026 14:53:19 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/i-tested-58-trading-hypotheses-48-failed-heres-the-framework-that-killed-them-1poe</link>
      <guid>https://dev.to/pavel_kkkkazantsev/i-tested-58-trading-hypotheses-48-failed-heres-the-framework-that-killed-them-1poe</guid>
      <description>&lt;p&gt;58 hypotheses went in. 48 were rejected, 9 remained promising, and 1 could not be evaluated because the required data was unavailable.&lt;/p&gt;

&lt;p&gt;None of the 9 earned the label "edge." They earned the right to be tested further.&lt;/p&gt;

&lt;p&gt;Some ideas should die before a backtest is ever run. The rest should face the cheapest falsification test first. A hypothesis that cannot answer "who pays us?" and "why does this persist?" is not a hypothesis. It is a guess with a Python file attached.&lt;/p&gt;

&lt;p&gt;The kill pipeline that processed all 58, in the order most ideas die:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hypothesis idea
    ↓
cost filter        (does it survive fees and turnover at all?)
    ↓
robustness check   (is the result a plateau or a lucky parameter?)
    ↓
null benchmark     (does it beat a structure-preserving random version?)
    ↓
untouched holdout  (does it hold on a year it never saw?)
    ↓
contamination audit (is the result real or did the code lie to me?)
    ↓
survived research → forward / shadow validation → capital
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each gate catches a different type of false alpha. A strategy can clear the first four and die on the fifth. Passing all five is not a green light. It is permission to start collecting forward data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpo85kaonmubsvqtv7i28.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpo85kaonmubsvqtv7i28.png" alt="Full kill pipeline — from hypothesis through five gates to locked capital" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 1: costs
&lt;/h2&gt;

&lt;p&gt;The most common way a backtest looks good: skip costs. Cross-sectional reversal on 15-minute Bybit bars had a gross Sharpe of 7 to 13, positive in 80%+ of out-of-sample windows. The gross effect was clearly present before costs. At taker fees, rebalancing every bar, trading costs ran 5 to 10 times the size of the raw edge. After costs, zero out of 76 out-of-sample windows remained positive.&lt;/p&gt;

&lt;p&gt;I tried entry/exit thresholds, hysteresis, coarser timeframes, slower rebalancing. No plateau anywhere near break-even. Adjacent parameter settings swung from +45% to −60%.&lt;/p&gt;

&lt;p&gt;Run the cost test before you believe the signal, not after.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw2pd6n8hzhpydq0g2bw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw2pd6n8hzhpydq0g2bw.png" alt="Before costs: Sharpe 7–13, 80%+ OOS positive. After costs: 0/76 windows positive" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 2: robustness
&lt;/h2&gt;

&lt;p&gt;Daily momentum had one configuration that returned +97% net at taker fees. The lookback sweep: l=7 gave +33%, l=10 gave −23%, l=14 gave +97%, l=21 gave +35%. No plateau. The +97% was a lucky draw from a jagged surface.&lt;/p&gt;

&lt;p&gt;Half the return came from the 2021 bull alone. 2022 was negative. A strategy that only makes money in bull markets and loses in bear is more consistent with market beta than neutral alpha.&lt;/p&gt;

&lt;p&gt;One good result on a swept parameter is not evidence. The whole surface needs to hold up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqywzn80nl2eada0p8fiq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqywzn80nl2eada0p8fiq.png" alt="Momentum lookback sweep: l=7 +33%, l=10 -23%, l=14 +97%, l=21 +35% — no plateau" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 3: null benchmark
&lt;/h2&gt;

&lt;p&gt;Funding carry looked strong in development: +48.6% net over 4.5 years, Sharpe 1.17. Before trusting it, I ran a null test: scramble which coin's funding the strategy reads, keep everything else identical. The scrambled version returned +28%.&lt;/p&gt;

&lt;p&gt;More than half the return was the structural funding premium: exposure to perpetual market mechanics rather than signal-driven selection. The selection component showed evidence beyond this null in development (empirical permutation p=0.024), but it was much smaller than the headline return implied.&lt;/p&gt;

&lt;p&gt;Before believing the result, ask whether a random version of the signal would also make money.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsol5j9xz7qnu9jk21hvb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsol5j9xz7qnu9jk21hvb.png" alt="Funding carry +48.6% vs scrambled null +28%, selection component +20.6%, p=0.024" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 4: untouched holdout
&lt;/h2&gt;

&lt;p&gt;The lockbox is a date range fixed in advance, never touched until a single opening event. No research, no tuning, no plotting inside that range until development is complete.&lt;/p&gt;

&lt;p&gt;Funding carry passed development and validation. The lockbox was a 12-month period (2025-09 to 2026-09), sealed from day one. The frozen strategy made −0.5% on it. The premium that paid from 2021 to 2024 was absent in the lockbox year. One year of absence is not a permanent verdict. It is exactly the kind of regime dependence the lockbox is designed to expose early.&lt;/p&gt;

&lt;p&gt;Once a lockbox is opened, it is burned. Any further confirmation requires a new, still-unseen date range. Development results are hypotheses, and only sealed, untouched data can confirm them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F37fjbx5u2o4oqf7tw296.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F37fjbx5u2o4oqf7tw296.png" alt="Timeline 2021–2026: dev period +48.6%, lockbox 2025-09 to 2026-09 returned -0.5%" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 5: contamination audit
&lt;/h2&gt;

&lt;p&gt;One BTC funding-trend hypothesis had a development t-stat of 2.6. The 1,248 events had 72-hour holding periods, so observations were heavily overlapping. The Newey-West corrected t-stat fell from 2.6 to 1.07, removing the apparent statistical evidence from that test. A real-looking result from a real-looking test, broken by one statistical assumption.&lt;/p&gt;

&lt;p&gt;On a separate project, a football prediction model passed closing-line value (CLV) validation with +152bps, 95% CI excluded zero. A five-check forensic audit found two contaminated features. Four of five checks failed. The metric that reported the good result was the last place to look for the error.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz75s53wsykhdgntvam70.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz75s53wsykhdgntvam70.png" alt="1,248 overlapping 72h events, OLS t-stat 2.6 drops to 1.07 after Newey-West NW-9 correction" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 58 actually died
&lt;/h2&gt;

&lt;p&gt;Each hypothesis is assigned to the first decisive failure that stopped further evaluation. Some failed more than one check.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kill gate&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Costs&lt;/td&gt;
&lt;td&gt;~20&lt;/td&gt;
&lt;td&gt;Price MR: gross Sharpe 7–13, net negative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Robustness&lt;/td&gt;
&lt;td&gt;~10&lt;/td&gt;
&lt;td&gt;Daily momentum: +97% was one lucky lookback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Null benchmark&lt;/td&gt;
&lt;td&gt;~8&lt;/td&gt;
&lt;td&gt;Funding carry null still returned +28%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holdout / fresh OOS&lt;/td&gt;
&lt;td&gt;~7&lt;/td&gt;
&lt;td&gt;Funding carry −0.5% on sealed year; agent ensemble −35% OOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No detectable signal / other rejection&lt;/td&gt;
&lt;td&gt;~3&lt;/td&gt;
&lt;td&gt;New-listing cohort: corr≈0, null p=0.94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total rejected&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data unavailable&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Required data unavailable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remaining PROMISING&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Passed development and validation gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benjamini–Hochberg (BH) significant at FDR=0.05&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Independent mechanisms&lt;/td&gt;
&lt;td&gt;4–5&lt;/td&gt;
&lt;td&gt;roughly 4–5 distinct economic bets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;9 survivors does not mean 9 strategies. Several share the same economic mechanism with different entry filters. 8 Benjamini–Hochberg (BH) significant hypotheses at FDR=0.05 collapse to roughly 4–5 independent bets on market structure by economic mechanism.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fog0gpwz829ps7qqpjel3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fog0gpwz829ps7qqpjel3.png" alt="58 → 48 killed + 9 promising + 1 unavailable → 8 BH-significant → 4–5 independent mechanisms" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable failures
&lt;/h2&gt;

&lt;p&gt;Two cases that took longer than they should have.&lt;/p&gt;

&lt;p&gt;An LLM-generated factor-residual ensemble showed +76% in development, Sharpe 0.6, tuned over multiple iterations. On a fresh holdout it reversed to −35%. The development result held inside the data it was tuned on. The fresh holdout showed what the agent had actually learned: the development period.&lt;/p&gt;

&lt;p&gt;In a funding carry reproduction audit a month after the original publication, the holdout result changed from −0.5% to +1.3%. Same code, same data cache, fingerprints matched. I still do not have a clean explanation. I published the discrepancy table rather than quietly resolving it. I scrutinized the original negative result far less than I would have scrutinized a positive one. Negative results get a lower evidentiary standard by default. They should not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survived
&lt;/h2&gt;

&lt;p&gt;A handful of hypotheses passed enough gates to justify forward validation. They are currently shadow-deployed: signals recorded in real time, parameters locked, no capital.&lt;/p&gt;

&lt;p&gt;None of them earned the right to be trusted based on the backtest. The backtest is permission to collect more data, not permission to trade.&lt;/p&gt;

&lt;p&gt;The specific signals are not published here. Forward validation is ongoing and the gate has not been reached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steal this framework
&lt;/h2&gt;

&lt;p&gt;Before trusting any strategy result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Realistic costs included from the start, not added after
[ ] Parameter robustness: result holds over adjacent settings (plateau, not spike)
[ ] Null benchmark: structure-preserving random version loses
[ ] Untouched holdout: a date range sealed before any research on that data
[ ] Contamination audit: no look-ahead, no survivorship, no feature leakage
[ ] Forward validation: signals collected in real time, parameters locked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Costs and robustness killed roughly half of the 58 before a holdout was needed. Run the cheap gates first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdw9de57fmgkll91ne17g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdw9de57fmgkll91ne17g.png" alt="Research kill checklist: 6 gates from costs to forward validation" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;A high kill rate is not evidence that the pipeline is good. But a research process that rarely kills its own ideas is one I would distrust.&lt;/p&gt;

&lt;p&gt;48 of 58 died. If the kill rate were much lower, I would not trust the process.&lt;/p&gt;




&lt;p&gt;More experiments, engineering notes and quantitative research at pkazantsev.com&lt;/p&gt;

</description>
      <category>python</category>
      <category>trading</category>
      <category>datascience</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Green Tests, Broken Architecture: Four Models Review TypeScript Diffs</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Tue, 06 Oct 2026 10:20:38 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/green-tests-broken-architecture-four-models-review-typescript-diffs-16ge</link>
      <guid>https://dev.to/pavel_kkkkazantsev/green-tests-broken-architecture-four-models-review-typescript-diffs-16ge</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In ProgressCut, it is possible to write a change that works, passes a narrow test, and still violates a boundary I care about. A renderer can reach for Electron directly. A recovery record can be overwritten halfway through a write. An exported filename can escape its session folder.&lt;/p&gt;

&lt;p&gt;Those are review problems. I made a small benchmark to see how four models review short TypeScript diffs against explicit architecture rules.&lt;/p&gt;

&lt;p&gt;Three models got exactly &lt;strong&gt;0.949&lt;/strong&gt;. Each failed for a completely different reason.&lt;/p&gt;

&lt;p&gt;The public Kaggle task contains 13 compact synthetic diffs. Each has an expected APPROVE or BLOCK verdict, a violated invariant where applicable, and a requirement to cite evidence from the diff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3nmjetlwuru9u3wyvjct.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3nmjetlwuru9u3wyvjct.png" alt="Architecture-aware code review benchmark" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Benchmarked
&lt;/h2&gt;

&lt;p&gt;This is a deliberately narrow review exercise:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can a model reject a functionally plausible change when it violates an explicit repository invariant?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The diffs are synthetic and small. They are based on rules from ProgressCut, a local-first Electron application, but expose no private screenshots, sessions or unreleased code.&lt;/p&gt;

&lt;p&gt;The six rules under test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;engine-no-node&lt;/code&gt;: algorithm code cannot import Node or platform APIs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;domain-no-runtime-deps&lt;/code&gt;: domain types have no runtime-library dependency.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;renderer-no-electron&lt;/code&gt;: UI reaches native capabilities only through a narrow bridge.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;atomic-recovery-write&lt;/code&gt;: recovery metadata is written to a temporary file, then renamed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exact-render-duration&lt;/code&gt;: FFmpeg output is capped at the selected story duration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safe-output-path&lt;/code&gt;: an export cannot escape the configured session directory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Seven changes should be blocked and six should be approved. The approval cases sit close to the violations: a renderer may call a typed bridge, but may not import Electron; &lt;code&gt;resolve()&lt;/code&gt; plus a path-boundary check is acceptable, while &lt;code&gt;join(outputDir, userName)&lt;/code&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the score works
&lt;/h2&gt;

&lt;p&gt;Each case is worth one point, split across three deterministic checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;case score = (correct verdict + correct violated rule + grounded evidence) / 3
benchmark score = mean(case scores)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an approved change, &lt;code&gt;rule_id&lt;/code&gt; must be &lt;code&gt;none&lt;/code&gt;. A reviewer has to recognize when a change satisfies a rule, not merely spot its vocabulary.&lt;/p&gt;

&lt;p&gt;I ran the unchanged task once per model through Kaggle's benchmark runner. Every model received the same rules and diff. The scorer is deterministic; no LLM judges another LLM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;I used a small, deliberately mixed set from four providers, including the coding-focused Qwen3 Coder. This is not a claim of market coverage; it is a way to compare different review failure modes under the same prompt and scorer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Server runtime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;10m 48s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;0.949&lt;/td&gt;
&lt;td&gt;32.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;0.949&lt;/td&gt;
&lt;td&gt;14.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 Coder 480B&lt;/td&gt;
&lt;td&gt;0.949&lt;/td&gt;
&lt;td&gt;3m 29s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkr4jhuzh7veawmmzhipx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkr4jhuzh7veawmmzhipx.png" alt="Scorecard and distinct failure modes" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I had no baseline for whether 0.95 was good or bad, so the number alone told me little. With 13 cases, one third-point miss accounts for the whole gap. The failures were more informative than the scores.&lt;/p&gt;

&lt;p&gt;The three &lt;code&gt;0.949&lt;/code&gt; scores have the same arithmetic: twelve full points and one third-point case. They do not mean the same thing in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude
&lt;/h3&gt;

&lt;p&gt;For a direct &lt;code&gt;import { shell } from "electron"&lt;/code&gt; inside the renderer, Claude returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"BLOCK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rule_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"placeholder"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"placeholder"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verdict was right, but the response was unusable for automated enforcement. I cannot tell from this run whether the placeholders came from the model or its structured-output path. Either way, an agent pipeline needs schema validation and a retry or fallback path.&lt;/p&gt;

&lt;h3&gt;
  
  
  GPT-5.4 mini
&lt;/h3&gt;

&lt;p&gt;The safe version resolves the destination and verifies a separator-bounded prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestedName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid export path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GPT-5.4 mini blocked it anyway, arguing that the guard was unsafe. The benchmark expects approval: the guard also rejects the directory itself, which is conservative but not an escape route. This is a real review-agent failure mode: treating a stricter policy as a vulnerability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Qwen
&lt;/h3&gt;

&lt;p&gt;Qwen blocked a renderer function that takes a &lt;code&gt;DesktopBridge&lt;/code&gt; and calls &lt;code&gt;bridge.openFile(path)&lt;/code&gt;. Its explanation said the bridge ultimately uses Electron APIs.&lt;/p&gt;

&lt;p&gt;The bridge may eventually call Electron APIs, but the renderer does not receive Electron itself. Judging the rule from the call graph rather than the declared import boundary quietly changes its meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the perfect score does not mean
&lt;/h2&gt;

&lt;p&gt;Gemini's 1.000 is a result on 13 hand-written examples. It does not establish that Gemini is the best code reviewer, will enforce arbitrary policy, or is safe to run without tests and human review.&lt;/p&gt;

&lt;p&gt;The task has obvious limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one repository style and six named invariants;&lt;/li&gt;
&lt;li&gt;synthetic diffs rather than a history of production pull requests;&lt;/li&gt;
&lt;li&gt;no multi-file reasoning, tool use or test execution;&lt;/li&gt;
&lt;li&gt;one run per model, so no variance estimate;&lt;/li&gt;
&lt;li&gt;no analysis of cost or token usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a seed benchmark. Every failure can be inspected and reproduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would measure next
&lt;/h2&gt;

&lt;p&gt;The next version needs held-out fixtures and harder near-misses: indirect imports, unrelated noisy edits, an atomic write with bad cleanup, and path guards that look safe but accept sibling prefixes. I would also run repeated trials and report schema compliance separately from architecture reasoning.&lt;/p&gt;

&lt;p&gt;I would not reduce an AI review to one confidence score. In a real merge gate, I want separate signals for the policy verdict, the structured response, the cited evidence and the deterministic check.&lt;/p&gt;

&lt;p&gt;If the response is malformed, its policy decision should not be accepted. When a rule has a deterministic check, that check wins. The model reviews; deterministic logic makes the final decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;p&gt;The public leaderboard is &lt;a href="https://www.kaggle.com/benchmarks/pavelkazantsev7776/architecture-aware-typescript-code-review/versions/1" rel="noopener noreferrer"&gt;Architecture-Aware TypeScript Code Review on Kaggle&lt;/a&gt;. The underlying &lt;a href="https://www.kaggle.com/benchmarks/tasks/pavelkazantsev7776/safe-typescript-change-review-benchmark/1" rel="noopener noreferrer"&gt;Safe TypeScript Change Review task&lt;/a&gt; and the &lt;a href="https://github.com/icesurf666/progress-cut/tree/main/experiments/kaggle-safe-typescript-patches" rel="noopener noreferrer"&gt;task source and report generator&lt;/a&gt; are public too.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I Killed My Electron App Twice to Test Crash Recovery</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:36:40 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/i-killed-my-electron-app-twice-to-test-crash-recovery-3jgk</link>
      <guid>https://dev.to/pavel_kkkkazantsev/i-killed-my-electron-app-twice-to-test-crash-recovery-3jgk</guid>
      <description>&lt;p&gt;"We have crash recovery" is an easy thing to claim. I wanted to know what happens if the process disappears right now.&lt;/p&gt;

&lt;p&gt;The question is not specific to Electron. It applies to a media exporter, downloader, migration runner or AI workflow that writes files locally. Restarting an app is easy. Recovering enough state to finish the user's work is the part worth testing.&lt;/p&gt;

&lt;p&gt;I used an Electron recorder as the example below. The mechanics are simple: persist something, kill the process, restart, find the leftovers, rebuild, then inspect the result.&lt;/p&gt;

&lt;p&gt;For ProgressCut, a local-first desktop app that turns a work session into a short visual story, "right now" has two uncomfortable meanings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;while screenshots are being captured;&lt;/li&gt;
&lt;li&gt;while FFmpeg is assembling the final MP4.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In both cases, raw frames can exist while the output is partial or absent.&lt;/p&gt;

&lt;p&gt;I started isolated Electron processes, created real session folders, sent &lt;code&gt;SIGKILL&lt;/code&gt;, then started fresh Electron processes and asked them to find and rebuild what remained. &lt;code&gt;SIGKILL&lt;/code&gt; ends a process immediately, so shutdown handlers cannot quietly clean up the state first.&lt;/p&gt;

&lt;p&gt;The test found a rendering bug too: a story budgeted for 3.00 seconds encoded to 3.46 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4t9rguwzq5iucwz2572.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4t9rguwzq5iucwz2572.png" alt="Crash recovery lifecycle" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What had to survive
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;If there are captured frames, a later app process must be able to offer a rebuild without recording again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The implementation is a per-session manifest stored under the user's local application-support directory. It contains the output folder, raw-frame folder, target duration, output format and timestamps. It does not contain screenshot data.&lt;/p&gt;

&lt;p&gt;The record has to be per session. A single &lt;code&gt;active-session.json&lt;/code&gt; works until two incomplete sessions exist and the second one overwrites the first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;session A → recovery/&amp;lt;hash(A)&amp;gt;.json
session B → recovery/&amp;lt;hash(B)&amp;gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every manifest is written as a temporary file and then renamed into place. A successful recovery clears only the manifest for the recovered session.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgtslrprlohluz8362icf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgtslrprlohluz8362icf.png" alt="Per-session recovery lifecycle" width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually killed
&lt;/h2&gt;

&lt;p&gt;The smoke test uses synthetic PNGs instead of recording a real screen. Everything after that is production code: Electron startup, session orchestration, manifest storage, story selection and FFmpeg.&lt;/p&gt;

&lt;p&gt;The harness runs two scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Kill point&lt;/th&gt;
&lt;th&gt;What a restart must prove&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Capture interruption&lt;/td&gt;
&lt;td&gt;after the third PNG is persisted&lt;/td&gt;
&lt;td&gt;the three frames are discovered and remain byte-for-byte unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encoding interruption&lt;/td&gt;
&lt;td&gt;after the real FFmpeg process starts&lt;/td&gt;
&lt;td&gt;a fresh process finds the session and can render a valid MP4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The parent launches each Electron app in its own process group and sends &lt;code&gt;SIGKILL&lt;/code&gt; to that group. This matters: killing the Electron parent while leaving FFmpeg behind does not simulate an interruption cleanly.&lt;/p&gt;

&lt;p&gt;After each kill, a fresh Electron process reads local recovery manifests. The harness rebuilds every discovered session, runs &lt;code&gt;ffprobe&lt;/code&gt; (FFmpeg's inspection tool) on the output and verifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;H.264 video;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yuv420p&lt;/code&gt; pixel format;&lt;/li&gt;
&lt;li&gt;encoded duration within 150 ms of the requested three seconds;&lt;/li&gt;
&lt;li&gt;raw PNG bytes unchanged;&lt;/li&gt;
&lt;li&gt;recovery metadata removed only for the session that completed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The command is part of the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;test&lt;/span&gt;:crash-recovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  If you are testing a different app
&lt;/h2&gt;

&lt;p&gt;Replace "frames" with whatever your app leaves behind: uploaded chunks, edited documents, downloaded files, generated reports or queued jobs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choose the smallest irreversible user value. In my case, it is a persisted screenshot; in your app it might be a database checkpoint or a downloaded part file.&lt;/li&gt;
&lt;li&gt;Write a recoverable record before expensive work begins. Keep enough information to resume, but avoid storing sensitive content in that record.&lt;/li&gt;
&lt;li&gt;Interrupt the real process at a deterministic checkpoint. Do not mock the thing whose behaviour you are claiming to test.&lt;/li&gt;
&lt;li&gt;Start a fresh process with the same storage. A retry in the same memory space is not recovery.&lt;/li&gt;
&lt;li&gt;Verify the final artefact, then scope cleanup. Check bytes, schema, duration, checksum or domain result, not only that a file exists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In pseudocode it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkpoint:persisted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;killProcessGroup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;restartedApp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;launchWithSameDataDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;restartedApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discoverRecoverableWork&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;restartedApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toMatchObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedArtefact&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sourceBytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytesBeforeCrash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pendingRecords&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I check cleanup separately because it is easy to get wrong: stale records cause repeated recovery prompts, while broad cleanup can erase another unfinished job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure I wasn't looking for
&lt;/h2&gt;

&lt;p&gt;The first recovery run failed on a stricter check than "the file exists."&lt;/p&gt;

&lt;p&gt;The story target was three seconds. &lt;code&gt;ffprobe&lt;/code&gt; measured 3.458333 seconds.&lt;/p&gt;

&lt;p&gt;That was annoying, because the video looked fine. It played, the codec was right, nothing seemed broken. A test that only checked for a file would have passed it, and I would have shipped the bug.&lt;/p&gt;

&lt;p&gt;The renderer uses FFmpeg's concat demuxer because story moments have variable durations. The usual workaround repeats the last file so FFmpeg honours that last still frame. The side effect was extra tail time in the encoded video.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8c1856sknj4wxcqpzsl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8c1856sknj4wxcqpzsl.png" alt="The duration regression" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I capped the encoded output at the story's calculated duration and kept a regression test that creates weighted moments and measures the resulting file with &lt;code&gt;ffprobe&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runFfmpeg&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="c1"&gt;// concat input and video filter omitted&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-movflags&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+faststart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-t&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;story&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalDurationMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole reason to inspect the output rather than assert that a file exists. A report can exist with missing rows; a video can play and still have the wrong length.&lt;/p&gt;

&lt;h2&gt;
  
  
  What passed
&lt;/h2&gt;

&lt;p&gt;On 2026-10-05, the smoke ran on macOS 26.3, an arm64 Mac17,2 and Node.js 22.14.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;capture interruption  → 3 frames discovered → recovered
encoding interruption → 3 frames discovered → recovered
source PNGs           → unchanged
output                → H.264 / yuv420p / 3.00 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also tested the unsigned packaged arm64 &lt;code&gt;.app&lt;/code&gt; rather than only the development process. The app launched with a temporary profile, loaded Sharp from inside &lt;code&gt;app.asar&lt;/code&gt;, accepted a production IPC re-render request and generated a three-second MP4.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CSC_IDENTITY_AUTO_DISCOVERY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;pnpm run pack
pnpm &lt;span class="nb"&gt;test&lt;/span&gt;:packaged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches a different class of failure: code that works in a workspace because it accidentally resolves a native dependency from the developer machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does &lt;em&gt;not&lt;/em&gt; prove
&lt;/h2&gt;

&lt;p&gt;This covers process crashes and nothing more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not test power loss or filesystem durability beyond atomic rename.&lt;/li&gt;
&lt;li&gt;It does not test a multi-hour recording, sleep/wake, monitor changes or a full disk.&lt;/li&gt;
&lt;li&gt;It does not test Screen Recording permission UX.&lt;/li&gt;
&lt;li&gt;It does not test Developer ID signing, notarization, Gatekeeper or a clean-machine install.&lt;/li&gt;
&lt;li&gt;It does not evaluate whether the story selector makes better summaries. That requires real held-out sessions and blind review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I added this before release
&lt;/h2&gt;

&lt;p&gt;For a local-first tool, the useful data can already be on disk when the UI is gone. In this app, throwing away an unfinished session because FFmpeg died would be a bad default.&lt;/p&gt;

&lt;p&gt;Each run of the harness goes through the same loop:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3y2owtv5bqgp9gtepu6a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3y2owtv5bqgp9gtepu6a.png" alt="Crash recovery verification loop" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next I need a real two-to-five-hour session with resource measurements, sleep/wake and an intentional interruption. This smoke test narrows the gap; it does not replace that run.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/icesurf666/progress-cut" rel="noopener noreferrer"&gt;ProgressCut on GitHub&lt;/a&gt;, MIT licensed.&lt;/p&gt;

</description>
      <category>electron</category>
      <category>typescript</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Recorded 10,847 Frames While Coding. Only 30 Were Worth Keeping.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Fri, 02 Oct 2026 19:38:56 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/from-10000-frames-to-30-moments-building-a-visual-story-compressor-h5p</link>
      <guid>https://dev.to/pavel_kkkkazantsev/from-10000-frames-to-30-moments-building-a-visual-story-compressor-h5p</guid>
      <description>&lt;p&gt;I recorded a two-hour coding session and got 10,847 frames.&lt;/p&gt;

&lt;p&gt;The final timelapse needed only 30.&lt;/p&gt;

&lt;p&gt;So I built a pipeline to figure out which 30 were actually worth keeping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fpkazantsev.com%2Fprogresscut.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fpkazantsev.com%2Fprogresscut.gif" alt="ProgressCut compressing a coding session into a short visual story" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source session: &lt;a href="https://www.youtube.com/watch?v=GznmPACXBlY" rel="noopener noreferrer"&gt;youtube.com/watch?v=GznmPACXBlY&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most of those 10,847 frames were noise: the cursor blinked, the system clock ticked, while nothing meaningful in the code changed.&lt;/p&gt;

&lt;p&gt;The first step isn't clever. It's just getting rid of those.&lt;/p&gt;

&lt;p&gt;ProgressCut is a local macOS app that turns a coding session into a short visual story. No talking, no editing. You hit stop and it hands you a GIF. Here's how the algorithm actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compression pipeline
&lt;/h2&gt;

&lt;p&gt;Each stage removes a different kind of noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Capture → dHash dedupe → SSIM novelty → Segments → 30 moments
10,847  →    1,203     →     284      →    48     →     30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real numbers from a 2-hour session.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01n9ug3l3vw7t7xmc04z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01n9ug3l3vw7t7xmc04z.png" alt="Five-stage pipeline: 10,847 raw frames → dHash 1,203 → SSIM 284 → segments 48 → story 30 moments" width="800" height="217"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 1 — dHash: kill the identical frames
&lt;/h2&gt;

&lt;p&gt;89% gone in one pass. At 2 fps, consecutive screenshots are often nearly identical: the cursor blinks, the clock changes, or a single character appears while the rest of the screen stays untouched.&lt;/p&gt;

&lt;p&gt;dHash resizes each frame to 9×8 pixels, converts to greyscale, then compares each pixel to the one on its right (1 if brighter, 0 otherwise). 8 rows × 8 comparisons = 64 bits, stored as a &lt;code&gt;BigInt&lt;/code&gt;. XOR two hashes and count the differing bits. Distance ≤ 8: duplicate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwqxq5aym67h4oqqq4m8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwqxq5aym67h4oqqq4m8.png" alt="dHash pipeline: source frame to 9x8 greyscale grid to 64-bit comparison bits, Hamming distance decision" width="799" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Kernighan's bit trick — O(set bits) not O(64)
&lt;/h3&gt;

&lt;p&gt;Counting those differing bits naively loops 64 times. Kernighan's trick loops once per &lt;em&gt;set&lt;/em&gt; bit, so for near-duplicates that's typically 2–6 iterations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hammingDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DHash&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// XOR: 1 where bits differ&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;=&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// clears the lowest set bit&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why BigInt?&lt;/strong&gt; JS numbers are IEEE 754 doubles: they top out at 53 safe integer bits. A dHash is 64 bits, so &lt;code&gt;BigInt&lt;/code&gt; is the only way to get exact XOR. The &lt;code&gt;n&lt;/code&gt; suffix (&lt;code&gt;0n&lt;/code&gt;, &lt;code&gt;1n&lt;/code&gt;) is the literal syntax.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Result: 10,847 → 1,203 frames (−89%)&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 2 — SSIM: kill the boring frames
&lt;/h2&gt;

&lt;p&gt;Unique isn't the same as interesting. After dHash you still have ~1,200 frames that each look slightly different, but most of that difference is one more character on a line. You need a score that reflects how much the &lt;em&gt;visual state&lt;/em&gt; actually changed, not just whether the pixels moved.&lt;/p&gt;

&lt;p&gt;SSIM (Structural Similarity Index) compares luminance, contrast, and spatial structure between consecutive frames. Score close to 1: nothing happened. Score close to 0: something worth keeping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;         (2μₓμᵧ + C₁)(2σₓᵧ + C₂)
SSIM = ─────────────────────────────────
       (μₓ² + μᵧ² + C₁)(σₓ² + σᵧ² + C₂)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ls8d3mztp7o07eg4ssm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ls8d3mztp7o07eg4ssm.png" alt="Novelty score timeline showing spikes when significant visual changes occur, with selected moments marked as purple dots" width="800" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full-frame SSIM would average out a big change in one corner against stagnation everywhere else. Instead the frame is tiled into &lt;strong&gt;16 regions&lt;/strong&gt;, SSIM computed per region, then averaged. A file-save that changes the status bar doesn't steal credit from a tab switch that changed everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single-pass variance — one loop, not two
&lt;/h3&gt;

&lt;p&gt;The naive path does two loops: one for the mean, one for the variance. Both collapse into one using &lt;strong&gt;Var(X) = E[X²] − E[X]²&lt;/strong&gt;: accumulate Σx, Σx², Σy, Σy², Σxy in a single pass and derive everything after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sumA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sumB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sumAA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sumBB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sumAB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;sumA&lt;/span&gt;  &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="nx"&gt;sumB&lt;/span&gt;  &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;sumAA&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;sumBB&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pb&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;sumAB&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;muA&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sumA&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;muB&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sumB&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;varA&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sumAA&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;muA&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;muA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Var(X) = E[X²] − E[X]²&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;varB&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sumBB&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;muB&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;muB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;covAB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sumAB&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;muA&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;muB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result: 1,203 → 284 frames (−77%)&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 3 — Segmentation: budget the moments fairly
&lt;/h2&gt;

&lt;p&gt;Sessions aren't uniformly active. There's 20 minutes of flow where you're actually building something, then 10 minutes reading docs, then another sprint. If you spread the 30-moment budget evenly across the timeline, the reading gaps eat slots that should go to the interesting parts.&lt;/p&gt;

&lt;p&gt;The pipeline splits the frame sequence into activity windows by novelty density. Each window's share of the budget scales with how much happened inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warm-up │ ███████████ active  │ ░ idle │ ████████ active │ ████ wrap-up
 2 mom  │   12 moments        │ 1 mom  │  9 moments      │ 6 mom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcmiiwq24bmmi1db9og2z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcmiiwq24bmmi1db9og2z.png" alt="Temporal segmentation showing timeline divided into active and idle windows with budget bars below" width="800" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 20-minute sprint might take 8 moments; a 10-minute idle stretch gets 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result: 284 frames → 48 segments → 30 moments&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes out
&lt;/h2&gt;

&lt;p&gt;30 moments → a GIF or MP4, under 30 seconds, no input from you. Each moment holds for a duration proportional to the activity level of its segment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input:  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  10,847 frames
Output: █  ██   █████  █  ████  █  ██   ████      30 moments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjajme7lkuwux7y4kk97y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjajme7lkuwux7y4kk97y.png" alt="Compression ratio: 10,847 grey frames in the top bar, 30 purple moments in the bottom bar" width="797" height="97"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No frames leave the machine. The app calls no model. Run it twice on the same input and you get the same output. That last part matters more than it sounds: a non-deterministic story compressor is harder to trust and impossible to debug.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Better novelty scoring with embeddings&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SSIM can measure structural change, but it has no idea what that change means. Opening a new file and typing a few characters may affect a similar amount of screen area even though one is a much more meaningful transition.&lt;/p&gt;

&lt;p&gt;An embedding-based scorer might separate those cases better by measuring semantic change rather than raw pixel structure. That's the next hypothesis to test.&lt;/p&gt;

&lt;p&gt;The port in &lt;code&gt;packages/engine&lt;/code&gt; is already the swap point: implement a new scorer behind the same interface, run the same 187 tests, and leave the rest of the pipeline untouched.&lt;/p&gt;

&lt;p&gt;But swapping scorers without a way to evaluate the result is just guessing. The feedback loop comes first: let users remove moments they don't think belong in the final story, then use that data to compare scoring methods.&lt;/p&gt;

&lt;p&gt;SSIM stays the default until an embedding-based scorer demonstrates a measurable improvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Longer term: adaptive budget&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The 30-moment budget is fixed right now. A focused 20-minute session and a messy two-hour session both get 30 moments.&lt;/p&gt;

&lt;p&gt;The segmentation step already estimates how much activity happened in each window, so the next step is to let the total story length depend on the session itself.&lt;/p&gt;

&lt;p&gt;A short session with lots of meaningful changes might need fewer, faster moments. A long session with large idle stretches should compress harder instead of wasting slots on inactivity.&lt;/p&gt;

&lt;p&gt;If you were building this, would you keep SSIM or move to embeddings?&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;MIT, pnpm monorepo, 187 tests, CI on &lt;code&gt;macos-latest&lt;/code&gt;. The algorithm lives in &lt;code&gt;packages/engine/src/&lt;/code&gt; with no Electron dependency, runs in plain Node. If you want to poke at the frame selection logic without building the whole app, that's the entry point.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/icesurf666/progress-cut" rel="noopener noreferrer"&gt;github.com/icesurf666/progress-cut&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Three Crypto Edges. What Survived the Audit?</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Tue, 29 Sep 2026 15:13:08 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/three-crypto-edges-three-different-killers-1i6o</link>
      <guid>https://dev.to/pavel_kkkkazantsev/three-crypto-edges-three-different-killers-1i6o</guid>
      <description>&lt;p&gt;A research platform should not make it easy to stop at a good-looking number. That applies to negative results too. Transaction-cost checks, held-out evaluation, and parameter sweeps are useful only when their results can be traced back to their runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Candidate&lt;/th&gt;
&lt;th&gt;Historical report&lt;/th&gt;
&lt;th&gt;Current audit status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price mean reversion&lt;/td&gt;
&lt;td&gt;Gross Sharpe 7–13; rejected after costs&lt;/td&gt;
&lt;td&gt;Historical revision range not independently reproduced here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Funding carry&lt;/td&gt;
&lt;td&gt;+48.6% development; −0.5% holdout&lt;/td&gt;
&lt;td&gt;Rerun: +45.62% development; +1.31% holdout; discrepancy unresolved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily momentum&lt;/td&gt;
&lt;td&gt;+97% best case; sensitive to lookback&lt;/td&gt;
&lt;td&gt;Matching run/configuration not located; sweep remains unverified&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Edge 1: Mean reversion and the cost problem
&lt;/h2&gt;

&lt;p&gt;The historical summary reports cross-sectional reversal on 15-minute bars with gross Sharpe between 7 and 13, positive in over 80% of out-of-sample windows across 76 test periods. These figures span research revisions; they are not measurements from one controlled run.&lt;/p&gt;

&lt;p&gt;That summary also reports friction at 5–10 times the available edge when rebalancing every bar, and neighboring settings ranging from +45% to −60% net. Those revision-specific ratios and parameter results have not been independently reproduced in this audit. They should not be treated as verified evidence that every implementation must fail.&lt;/p&gt;

&lt;p&gt;The reported decision was to reject the tested implementations because a robust net result had not been established. The engineering question is whether a pre-cost pattern survives a specified execution model, not whether mean reversion is universally untradeable.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://pkazantsev.com/writing/crypto-backtest-trading-costs/" rel="noopener noreferrer"&gt;separate deep-dive&lt;/a&gt; describes a controlled evaluation. Its metrics should not be mixed with this historical revision summary as if they came from one run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge 2: Funding carry and a failed reproduction
&lt;/h2&gt;

&lt;p&gt;The tested carry construction combines long spot with short perpetual exposure and funding payments. Lower turnover reduces transaction costs; it does not eliminate them or other risks.&lt;/p&gt;

&lt;p&gt;The original report gives +48.6% net development return, Sharpe 1.17, and −0.5% net holdout return, Sharpe −0.02. On September 30, I reran the current implementation against the current local cache. Both the original evaluation script and a separate audit runner produced different results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Historical report&lt;/th&gt;
&lt;th&gt;Current rerun&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Development net cumulative return&lt;/td&gt;
&lt;td&gt;+48.6%&lt;/td&gt;
&lt;td&gt;+45.62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development Sharpe&lt;/td&gt;
&lt;td&gt;1.17&lt;/td&gt;
&lt;td&gt;1.079&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holdout net cumulative return&lt;/td&gt;
&lt;td&gt;−0.5%&lt;/td&gt;
&lt;td&gt;+1.31%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holdout Sharpe&lt;/td&gt;
&lt;td&gt;−0.02&lt;/td&gt;
&lt;td&gt;0.208&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ard1hfoiqgo9yxmemq8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ard1hfoiqgo9yxmemq8.png" alt="Historical and current funding-carry holdout returns have different signs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Historical report: −0.5%. Current rerun: +1.31%. These are different run versions, not a controlled experiment measuring an improvement. The cause of the discrepancy is not established.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The current configuration uses FundingCarry(384), rebalances every 1344 15-minute bars (14 days), normalizes to fixed gross exposure, and applies modelled fees of 4 bps plus slippage of 2 bps. It selects 45 symbols and scores 76 development windows and 17 holdout windows.&lt;/p&gt;

&lt;p&gt;The 17 complete 21-day holdout windows cover September 1, 2025 through August 24, 2026 (exclusive): 357 days, not the entire configured interval ending September 1. The original saved window list was not found, so historical coverage cannot be independently certified.&lt;/p&gt;

&lt;p&gt;In the current rerun, gross holdout return is +2.14% and net return is +1.31% on the same windows. Costs reduce the result under this model. This comparison does not explain the historical −0.5% return. Development returns are positive in each represented calendar year, including +3.44% in 2022; that does not recover the original annual figures.&lt;/p&gt;

&lt;p&gt;The audit runner fingerprints source code and cached input files before and after execution, saves scenario returns, and independently recalculates cumulative return and Sharpe from those returns. Those checks passed, as did 14 relevant engine tests. They check these calculations, not every modelling assumption or the historical result.&lt;/p&gt;

&lt;p&gt;The current universe filter uses funding-record counts from the cache before date restriction. Applying the threshold using only pre-holdout records yielded the same 45-symbol set in this cache. That does not establish point-in-time eligibility throughout development. No original run manifest or committed HFM code history was available to recover the historical code, universe and dataset. Nor does this audit prove the reserved interval was originally untouched.&lt;/p&gt;

&lt;p&gt;The earlier development null comparison, reported at about +28%, was not reproduced in this audit. It is not used here to infer a market-wide premium or selection skill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Null diagnostics on the current rerun&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two structure-preserving null tests were run post-hoc on the 17 holdout windows of the current reproduction result (net +1.31%, Sharpe +0.208). These diagnostics do not apply to the historical result and do not resolve the discrepancy. They ask two separable questions: did the temporal alignment between position decisions and outcomes contribute, and did the specific coin-selection rule add anything beyond random assignment?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CircularShiftNull — temporal alignment.&lt;/strong&gt; For each coin &lt;em&gt;i&lt;/em&gt;, a shift τᵢ is drawn uniformly from [0, T). The weight path becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;w̃ᵢ(t) = wᵢ((t + τᵢ) mod T)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this preserves: per-asset weight distribution, autocorrelation structure, holding period, and per-asset turnover &lt;code&gt;Σₜ |wᵢ(t) − wᵢ(t−1)|&lt;/code&gt;. What it destroys: the alignment between a position decision and the specific return interval it faced. H₀: the observed Sharpe is no better than an arbitrarily time-shifted version of the same weight paths. N = 500 seeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RebalanceSelectionNull — coin selection.&lt;/strong&gt; Rebalance events are detected as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T_R = {t : ‖w(t) − w(t−1)‖₁ &amp;gt; ε}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At each t ∈ T_R, the weight vector is permuted across universe symbols. What this preserves: rebalance schedule, gross exposure &lt;code&gt;Σᵢ|wᵢ|&lt;/code&gt;, number of active positions, weight magnitudes. What it destroys: which symbol receives which weight at each rebalance. N = 500 seeds.&lt;/p&gt;

&lt;p&gt;Both use the Davison-Hinkley finite-sample p-value to avoid the p = 0 artifact from small N:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p = (1 + #{θ_null ≥ θ_obs}) / (N + 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Turnover inflation.&lt;/strong&gt; CircularShiftNull wraps each coin's weight path at the window boundary. When τᵢ &amp;gt; 0, the series at time T − τᵢ jumps discontinuously to the value at time 0, creating large simultaneous &lt;code&gt;|Δw|&lt;/code&gt; spikes at the seam. Mean null turnover reached 1.82× frozen. RebalanceSelectionNull avoids the wrap-around artifact but permutes across the full universe rather than the positive-funding eligible set, changing the effective cost profile. Mean null turnover: 1.34× frozen. In both cases the frozen strategy received a structural cost advantage independent of signal quality. The CircularShift p-value (0.010) reflects a mix of genuine temporal misalignment loss and artificial cost inflation that cannot be cleanly separated. These p-values are reported for transparency, not as evidence.&lt;/p&gt;

&lt;p&gt;The right next step is a funding-matched null: at each rebalance, permute weight assignments only within the positive-funding eligible set. This would hold funding-exposure loading constant and isolate the coin-ranking question directly. It was not implemented because the execution API does not expose the contemporaneous panel signal needed to define the eligible set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharpe uncertainty via block bootstrap.&lt;/strong&gt; The 17 holdout windows are serially dependent: consecutive windows share overlapping formation data, and 21-day non-overlapping hold periods are far too short for serial correlation to decay. The naive standard error &lt;code&gt;1/√17 ≈ 0.24&lt;/code&gt; treats windows as independent and understates uncertainty by a material factor.&lt;/p&gt;

&lt;p&gt;A moving-block bootstrap was run on the 15-minute holdout return series. Block length L = 4032 bars corresponds to approximately 3 full rebalance cycles (~63 days), chosen to span the dominant autocorrelation horizon of the strategy. N = 2000 resamples; annualization factor √(365 × 24 × 4).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;point estimate:      Sharpe +0.208
95% CI (L = 4032):  [−0.21, +0.44]
p(Sharpe &amp;gt; 0):       78.2%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 95% interval spans zero. With 17 non-overlapping windows, wide CIs are the honest result, not a flaw in the method.&lt;/p&gt;

&lt;p&gt;The historical REJECT decision is part of the research record, but a negative holdout is no longer a verified rationale in this article. The candidate remains on hold pending reconciliation. The positive rerun alone does not establish a tradeable edge. Analysis of this already-opened interval is exploratory, not a new out-of-sample confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge 3: Momentum and an unverified parameter sweep
&lt;/h2&gt;

&lt;p&gt;The historical summary describes daily cross-sectional momentum with weekly rebalancing and a best net return of approximately +97%, including taker fees. It gives four lookback results: 7 days +33%, 10 days −23%, 14 days +97%, and 21 days +35%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxz07ile4ex2ds4j0aijo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxz07ile4ex2ds4j0aijo.png" alt="Historically reported momentum returns at four lookbacks, not independently reproduced" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Values transcribed from the frozen narrative report. A matching saved run/configuration was not located in this check. These are not four independently validated results.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If reproduced, that sweep would raise a parameter-sensitivity concern: the best setting is not surrounded by comparably strong results. It would not, by itself, prove the peak was luck. The historical summary also attributes about half the return to 2021 and reports a negative 2022. Those regime figures remain unverified here; the report did not estimate a formal beta decomposition.&lt;/p&gt;

&lt;p&gt;The reported WEAK verdict is a historical assessment, not a newly verified finding from this audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the audit actually established
&lt;/h2&gt;

&lt;p&gt;The current carry calculation is reproducible locally with recorded code and input hashes. Its headline values do not match the historical report. For the mean-reversion revision range and momentum sweep, narrative evidence is available but numerical reproduction remains incomplete.&lt;/p&gt;

&lt;p&gt;That is a less tidy conclusion than three precise diagnoses. It is also the result the evidence supports. A negative backtest deserves the same provenance checks as a profitable one.&lt;/p&gt;

&lt;p&gt;No candidate is being advanced to live trading on the basis of this article. The next step is to recover the historical run artifacts or explicitly retire unsupported historical numbers, not silently substitute new results into the old story.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/icesurf666/quant-research-notes/tree/main/studies/round2-three-edges-three-killers" rel="noopener noreferrer"&gt;research package&lt;/a&gt; preserves the original narrative evidence. This is not a complete independent reproduction of the private data pipeline. SHA-256 checks establish file identity, not economic validity. The frozen source retains stronger claims that this corrected article does not endorse.&lt;/p&gt;

&lt;p&gt;Evidence source SHA-256: &lt;code&gt;cf9ce73542af24be3e5c2872864b2b41c6ce864e3b2f07b760e5434a1bdb23ba&lt;/code&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Research system: HFM · Historical reports and a post-publication rerun · Bybit spot and perpetuals · Python&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>python</category>
      <category>datascience</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>My Football Model Passed Validation. A Five-Check Audit Killed It.</title>
      <dc:creator>Pavel Kazantsev 💎</dc:creator>
      <pubDate>Sun, 27 Sep 2026 07:08:32 +0000</pubDate>
      <link>https://dev.to/pavel_kkkkazantsev/my-football-model-passed-validation-a-check-audit-killed-it-37f4</link>
      <guid>https://dev.to/pavel_kkkkazantsev/my-football-model-passed-validation-a-check-audit-killed-it-37f4</guid>
      <description>&lt;p&gt;My football prediction model passed validation. Val away CLV was +152 bps, confidence interval excluded zero. Then I ran five forensic checks. Four failed, one passed, and the pass made the four failures harder to argue away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;The hypothesis: EPL bookmakers systematically underprice high-odds away teams. A LightGBM classifier trained on seven seasons of opening-line data would find those games and generate positive closing line value.&lt;/p&gt;

&lt;p&gt;CLV is &lt;code&gt;mean(model_prob - devigged_closing_prob)&lt;/code&gt;. Devigging removes the bookmaker's margin from decimal odds to recover fair probabilities that sum to 1. Positive CLV means your model put higher probability on an outcome than the final Pinnacle price did, after sharp money finished moving it. It is the standard measure of model skill in sports prediction research.&lt;/p&gt;

&lt;p&gt;14 features, all opening-line: devigged Pinnacle probabilities, B365, market consensus, Pinnacle-vs-consensus disagreement, raw decimal odds. No temporal index, no threshold from the full dataset. LightGBM with &lt;code&gt;n_estimators=300, learning_rate=0.03, num_leaves=15&lt;/code&gt;, hyperparameters locked before touching val. Logistic regression as a same-feature baseline. 380 val games (EPL 2022-23). One pre-registered hypothesis: AWAY outcome.&lt;/p&gt;

&lt;p&gt;The surface result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Split&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;CLV&lt;/th&gt;
&lt;th&gt;CI&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Val 2223&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Away&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+152 bps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;[+55, +251]&lt;/td&gt;
&lt;td&gt;CLV_ONLY_BRIER_FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Val 2223&lt;/td&gt;
&lt;td&gt;Home&lt;/td&gt;
&lt;td&gt;−39 bps&lt;/td&gt;
&lt;td&gt;[−132, +52]&lt;/td&gt;
&lt;td&gt;NULL_RESULT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test 2324&lt;/td&gt;
&lt;td&gt;Away&lt;/td&gt;
&lt;td&gt;+11 bps&lt;/td&gt;
&lt;td&gt;[−79, +100]&lt;/td&gt;
&lt;td&gt;NULL_RESULT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test 2324&lt;/td&gt;
&lt;td&gt;Home&lt;/td&gt;
&lt;td&gt;+176 bps&lt;/td&gt;
&lt;td&gt;[+95, +263]&lt;/td&gt;
&lt;td&gt;CLV_ONLY_BRIER_FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Val away CI excludes zero, which is what the hypothesis required. The verdict column reads &lt;code&gt;CLV_ONLY_BRIER_FAIL&lt;/code&gt; rather than &lt;code&gt;EDGE_CANDIDATE&lt;/code&gt; because the protocol requires positive CLV &lt;em&gt;and&lt;/em&gt; Brier improvement over the closing line. Five forensic checks determined why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1: What does the market itself drift?
&lt;/h2&gt;

&lt;p&gt;Any model trained on opening odds will inherit whatever directional drift the market already has between open and close. Measure that drift first, with no model, so you know what the baseline is.&lt;/p&gt;

&lt;p&gt;Val away drift: &lt;strong&gt;+3.9 bps&lt;/strong&gt; (CI: −19.5 to +26.8, crosses zero). No systematic directional movement in 2022-23 for away outcomes.&lt;/p&gt;

&lt;p&gt;The model showed +152 bps on the same split, so +148 bps was model-generated with no structural drift to explain it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2: Brier skill score
&lt;/h2&gt;

&lt;p&gt;A model that systematically overestimates probabilities in the right direction will show positive CLV while being less accurate than the market. Brier score separates the two. To claim predictive value, the model needs a lower Brier score than the closing line on the same games.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwdgixxuncaocste2j8v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwdgixxuncaocste2j8v.png" alt="Model Brier score vs closing line across 6 split-outcome combinations. Lower is better." width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Model Brier vs closing line, all 6 split-outcome combinations. Lower is better. The model is worse on every single one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model lost on Brier in all six combinations. Delta ranged from +0.004 to +0.019, consistently worse than the closing line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model can beat the closing line on CLV while losing to it on Brier. It is overestimating probabilities in the right direction at the wrong magnitude. That is systematic bias.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Check 3: Feature cleanliness
&lt;/h2&gt;

&lt;p&gt;The first version of this experiment had two contaminated features. &lt;code&gt;season_num&lt;/code&gt; was a global index 0-8 derived from the full nine-season dataset, so val season got index 7 and the model extrapolated outside its training range. &lt;code&gt;is_high_odds_away&lt;/code&gt; was a binary flag using a threshold of 0.62 set by a calibration run on all nine seasons, including val and test.&lt;/p&gt;

&lt;p&gt;I removed both and reran from scratch with 14 features: opening odds from Pinnacle, B365, and market consensus; bookmaker disagreement; raw decimal odds. All from pre-match data only. Check 3: PASS. Zero violations.&lt;/p&gt;

&lt;p&gt;Removing the contaminated features dropped val away CLV from +216 bps to +152 bps, so the contamination was inflating the result by about 64 bps. With a clean feature set, the remaining failures have nothing to blame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 4: Incremental CLV
&lt;/h2&gt;

&lt;p&gt;Standard CLV is &lt;code&gt;model_prob - close_prob&lt;/code&gt;. To separate the model's contribution from the market's own drift, compute &lt;code&gt;model_prob - open_prob&lt;/code&gt; instead. The closing line cancels algebraically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(model - close) - (open - close) = model - open
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6u4iz8ai65cczb4vbt6p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6u4iz8ai65cczb4vbt6p.png" alt="Val away CLV decomposed. Market drifted +3.9 bps. Model added +147.7 bps." width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Val away CLV decomposed. Market drifted +3.9 bps. Model added +147.7 bps. The model contributes 38x the underlying signal.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model pushed away probabilities &lt;strong&gt;147.7 bps above the opening line&lt;/strong&gt; (CI: [+52.5, +244.4]). True market drift was +3.9 bps. The model overshot by 38x.&lt;/p&gt;

&lt;p&gt;On test, the same model produced +11 bps CLV for away (CI: [−79, +100], crosses zero). The model was overestimating away probabilities in both splits. In val that overshoot happened to align with a small positive drift. In test it produced noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 5: Season stability
&lt;/h2&gt;

&lt;p&gt;If removing one training season flips the sign of the result, the result depends on that season, not on a real underlying relationship. Leave-one-out validation removes one training season at a time and re-measures val CLV.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Mean CLV&lt;/th&gt;
&lt;th&gt;Std&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;Sign stable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home&lt;/td&gt;
&lt;td&gt;−39.7 bps&lt;/td&gt;
&lt;td&gt;85.6&lt;/td&gt;
&lt;td&gt;[−200, +114]&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draw&lt;/td&gt;
&lt;td&gt;−134.2 bps&lt;/td&gt;
&lt;td&gt;44.2&lt;/td&gt;
&lt;td&gt;[−213, −81]&lt;/td&gt;
&lt;td&gt;Yes (consistently negative)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Away&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+106.3 bps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;126.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;[−151, +314]&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No (sign flips)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Away CLV standard deviation: 126.6 bps. Removing season 2020-21 gives +314 bps, removing season 2021-22 gives −151 bps. The val result is entirely a function of which seasons happened to land in the training set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four failures. One pass. Verdict unchanged.
&lt;/h2&gt;

&lt;p&gt;Fixing the contamination improved the experiment, not the result. The core failures are independent of the bad features.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opening-line baseline&lt;/td&gt;
&lt;td&gt;True drift +3.9 bps; model added 38x more&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brier skill score&lt;/td&gt;
&lt;td&gt;Model worse than market on all 6 combinations&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature cleanliness&lt;/td&gt;
&lt;td&gt;14 features, zero violations, clean rerun&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental CLV&lt;/td&gt;
&lt;td&gt;Model overshoots by 148 bps; collapses to noise on test&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Season stability&lt;/td&gt;
&lt;td&gt;Std 126.6 bps, sign flips on leave-one-out&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Logistic regression on the same features: val away CLV +87 bps (CI: [+57, +117]), Brier 0.185 vs closing 0.182, test away −102 bps (CI: [−135, −71]). Same pattern. The calibration failure is not specific to LightGBM.&lt;/p&gt;

&lt;p&gt;All four failing checks trace to the same thing: the model overestimates away probabilities. In 2022-23 that overshoot happened to align with a small and statistically insignificant positive drift. In 2023-24 it produced +11 bps with a CI that crosses zero. REJECT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contamination fix that changed nothing
&lt;/h2&gt;

&lt;p&gt;Val away CLV dropped from 216 to 152 bps when the contaminated features were removed. The contamination inflated the number. But 152 bps still excludes zero, so one could argue the signal survived.&lt;/p&gt;

&lt;p&gt;Brier closes that argument. With clean features, on both val and test, the model is less accurate than the closing line on all six outcome-split combinations. A model that cannot beat the market's accuracy is overfit, not predictive. The contamination was real. Removing it changed the CLV. The Brier failure is independent and survived the cleanup.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Finding contamination and fixing it is not enough. The clean rerun has to pass all gates. This one does not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a genuine signal would require
&lt;/h2&gt;

&lt;p&gt;To restart this experiment honestly, the protocol needs harder gates. Brier improvement over closing as a required gate, not a diagnostic. Sign-stable LOO results before the test set is touched at all. Multi-league or multi-season test data, because one EPL season (380 games) is too small for confirmation at this effect size. At 150 bps, 380 games gives borderline statistical power and cannot separate a real edge from model miscalibration of the same magnitude. That is a different experiment from what this was.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Research system: HFM · LightGBM · 9 EPL seasons · Walk-forward OOS · Clean features v2&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
      <category>computerscience</category>
    </item>
  </channel>
</rss>
