Algorithm research & validation history

Audience: a future agent (or human) changing tacita’s download/ad-cutting behavior. This is the empirical record behind the shipped design — what was tried, what killed each earlier approach, and which invariants exist because a cheaper idea was proven wrong on real servings. Read this before “improving” the algorithm: several obvious-looking optimizations below were implemented, looked correct in byte analysis, and were reverted after they were shown to cut real show content. Update this doc whenever the process changes (see .agents/update-algorithm-doc-skill/).

All research was done 2026-07-02 → 2026-07-03 against live Acast and Audioboom feeds during the original implementation in podcast-puller-2 (PRs #5/#6 there hold the commit-level history; the private ~/worklogs entries hold the raw session logs).

Vocabulary

The serving model (empirical, both hosts)

  1. Ad fill is User-Agent dependent. The same Acast URL returned 42:49 via curl UA and 51:07 via iPhone UA.
  2. The “app tier” (okhttp — also curl, wget, googlebot) received a stable canonical stitch: byte-identical across requests, duration exactly the feed’s declared duration. Only “player” UAs (vlc, firefox, pocketcasts, AppleCoreMedia) received dynamic fill — every request differed.
  3. Fill decisions are sticky short-term: back-to-back downloads with the same client were byte-identical. Fill rotates over hours (a 2-hour-old copy differed). Time separation between same-tier copies is therefore the accuracy lever.
  4. Different tiers substitute material — a baked-in segment present in the app tier was absent from player-tier servings. A cross-tier reference therefore over-cuts real content (this killed design #2 below).
  5. Tacita pins the default UA (okhttp/4.12.0) because the served bytes — and every conclusion in this document — depend on the tier.

Field observations from podcast-hacker (2026-07-04, tacita 0.0.1)

First real-consumer data, from podcast-hacker’s stage-6 verification (okhttp engine, so app tier; fresh installs, so every download was an immediate back-to-back primary+reference pair — podcast-hacker v0 also deletes references after each download, guaranteeing that pairing on every run):

Takeaway for consumers: the back-to-back blind spot isn’t hypothetical — reference age is the accuracy lever, so apps should persist references across sessions (or otherwise separate the pair in time) rather than re-downloading both copies together.

2026-07-04 follow-up: the app tier is no longer exempt (Audioboom, Simplecast)

Live probes against the failing feeds (The Nextlander Podcast on Audioboom, Conan on Simplecast), latest episodes, curl under different UAs, all numbers Content-Length:

So both field failures share one cause: on these hosts the pinned okhttp tier is a filled tier with sticky fill, which blinds an immediate same-tier reference — while a provably clean copy is discoverable out-of-band. Tier membership is host-specific: okhttp is “app/clean” on Acast but “player/filled” on Audioboom and Simplecast.

2026-07-05: Audioboom DAI leaks are per-show, not per-host (Pod Save America)

First real-feed runs of the aggressive candidate pass (probe harness on the published 0.0.3-SNAPSHOT), prompted by a consumer report that PSA’s pre-roll ads produced no candidates. Pod Save America episode 8923605 (“Trump’s 4th Threesome”, declared itunes:duration 4562s, enclosure length="0" as usual for Audioboom):

Dead ends (each looked correct in byte analysis)

#1 — Segment-length classification (shipped briefly, reverted)

Parse the stitch structure (tag frames), classify short segments as ads. Worked perfectly on an S1-remastered episode (9 segments: 4 long content, 2 ad blocks, jingle, empty slots). Killed by a later-season episode (S5E11):

#2 — Cross-tier reference + gap-length threshold (shipped briefly, reverted)

Diff against a copy fetched with a different UA; recover markerless joins only when the shared run is long (minRecoverSeconds=300, from the observation that markerless content runs were ~784s while pinned creatives were ≤170s). Produced a beautiful-looking cut of S5E11 (393s). The user listened to the cut points: they were not ads. The bytes unique to the app-tier copy were baked-in show material the player tier legitimately doesn’t receive (serving-model fact 4). This would have cut ~6.5 minutes of real show. Both ideas (different-UA reference, length-threshold recovery) were removed the same day.

Meta-lesson (this happened twice): byte structure genuinely looks ad-shaped. Every wrong theory was “confirmed” by byte analysis. The only reliable oracle was a human listening at the proposed cut points. Ask for an ear check before believing a new ad map.

Also ruled out

The shipped design

Diff the file against a same-tier reference (downloaded exactly like the primary — same client, same UA). Byte runs present in both copies are kept; runs unique to the primary are the injected ads. Identical copies → NoAdsFound.

The invariant everything above serves

The failure mode is always keeping too much, never cutting episode material. Bytes shared between two same-tier copies are never cut, regardless of how ad-like they look. Any change that can cut shared bytes, use a cross-tier reference, or classify by duration/structure alone re-opens a failure mode that was ear-verified twice.

Measured results (real servings, 2026-07-03)

Case Result
Audioboom ~70min special, filled vs clean serving all 4 markerless insertions cut (270.79s); output audio byte-identical to the clean serving (only delta: trailing 128-byte ID3v1 tag, dropped with the post-roll)
Audioboom chapters after cut all 23 CHAP times match the clean serving; final chapter end drifts 0.037s (pointed inside the post-roll cut)
Acast S5E11, iPhone-filled vs 2h-old same-tier copy 378.3s of 423.3s ground-truth fill cut (89%); remainder was byte-shared sticky creatives (designed under-cut); zero content cut
Acast S5E11, filled vs canonical 347.3s cut; output stayed 76s above canonical = zero content cut
Identical pairs (several episodes) NoAdsFound, byte-untouched
Decode checks ffmpeg -f null full-file and splice-region decodes: zero errors, every case
Performance ~4s for a 70MB file (JVM)
Nextlander 8923058 via Audioboom static fallback (2026-07-04) 66,843,410 bytes, decodes clean, duration 4170.37s vs declared 4170; ear-verified clean at both leaked mid-roll slots (ghackett, 2026-07-05)
Conan 6e993d49 via Simplecast bot tier (2026-07-04) 58,529,431 bytes vs declared 58,529,858, decodes clean, 3658.08s vs declared 3658; ear-verified: pre-roll ads gone (ghackett, 2026-07-05)

Alternative-detection research (2026-07-04)

Surveyed after the Nextlander/Conan failures, looking for approaches that don’t depend on a same-tier reference (including waveform-level ideas). Findings, so nobody re-runs this:

Cross-episode creative reuse — the fingerprint experiment (2026-07-19)

Prompted by a proposal (ghackett) to add human-confirmed ad fingerprints: a consumer app surfaces a suspected ad, a human confirms it is one, tacita stores a fingerprint and cuts that creative from future episodes (tacita owns the logic only, never the UI). The load-bearing question for viability: does the same creative recur byte-identically across different episodes of a feed, or does the ad platform re-transcode per serving? Byte-identity decides whether fingerprints can reuse the existing rolling-hash machinery or need an mp3 decoder + acoustic (PCM-level) fingerprints across every KMP target.

Method (live probes, 2026-07-19): The Nextlander Podcast (Audioboom), episodes 8923058 (the known 4170s special) and 8920601 (ep 254, 10737s), fetched minutes apart: filled okhttp-tier copies (sticky — 1-byte-Range probed twice each: 71,444,498 / 176,995,454 bytes) plus each episode’s leaked fallback_url static copy (66,843,410 — byte-count identical to the 07-04 measurement, so the static copy is stable across 15 days — and 171,912,062). Insertion-only alignment of filled vs clean recovered each episode’s injected fill exactly (both files fully consumed by the walk); shared-run analysis (≥4KB runs, any byte alignment, hash-seeded + byte-verified + maximally extended) compared the two fills; collision checks compared each fill against the other episode’s clean copy, and 8923058’s clean intro/outro (5min each) against all of 8920601’s clean copy (≥2KB).

Findings:

Same day, second host: Simplecast normalizes creatives per episode (Conan)

Same protocol against Conan (Simplecast), “Mick Jagger” (3602s, declared 57,632,505) and “Danny McBride Returns” (3435s, declared 54,971,777): okhttp-tier filled copies (sticky, probed twice: 66,523,320 / 62,648,835) vs bot-tier clean canonicals (57,632,078 / 54,971,350 ≈ declared). Findings, materially different from Audioboom:

Verdict

Viable — as a layered design, with the layer depending on host class:

Literature (2026-07-19 web survey, agent-assisted) supports the acoustic layer when needed: audfprint (MIT) ships a documented known-ad-search workflow with field-tuned parameters (11.025kHz, ~100 hashes/s for ads, high min-hit thresholds and time-range output); Olaf proves Wang-style matching runs on microcontrollers (compute is a non-issue); Chromaprint is a whole-track matcher, wrong shape for ad-in-episode search; SponsorBlock-style crowdsourced timestamps are structurally broken for DAI (per-listener stitches) while fingerprints are position-independent — which is exactly this proposal; industry guidance puts creative rotation at 2–4 weeks and campaign flights at 4–8 weeks, so confirmed fingerprints should stay useful for weeks and (because DAI backfills old episodes with current campaigns) fingerprints learned on new episodes also clean back-catalog downloads. No published source addresses byte-stability of re-served creatives — the probes above are ahead of public knowledge there.

Design constraints recorded now so they survive until it’s built:

Shipped: the byte-level layer (2026-07-19, log/candidate-only)

Landed the same day as the research above, honoring every constraint it recorded:

Open before graduating matches from candidates to cuts: real-feed ear verification of matched spans (playbook step 5). Still open from the research: Acast player-tier reuse measurement, cross-week creative recurrence (the fill-rotation shelf life of a stored fingerprint), and the acoustic layer for Simplecast-class hosts.

Store scoping (2026-07-19 follow-up): per-feed for the byte layer, global for the acoustic layer

Questioned same-day (ghackett): the same ad campaigns do run across many feeds, so why scope stores per-feed? Recording the reasoning so the decision doesn’t fossilize as unexamined dogma:

Acoustic-layer groundwork: common-code mp3 decoder (2026-07-19, additive)

The acoustic layer’s known large lift — a PCM decoder that runs on every KMP target — is done: Mp3Decoder (internal) is a hand-port of minimp3 (CC0) to common Kotlin, in the exact configuration ported from (scalar path, float output, Layer III only; MPEG-1/2/2.5, mono through joint stereo, bit reservoir, free format). The constant tables were extracted mechanically from minimp3.h by scripts/port-minimp3-tables.py — ~3,000 values with zero hand transcription.

Verification (2026-07-19): the port was compared against the C minimp3 compiled locally (gcc 15.2, same MINIMP3_ONLY_MP3/MINIMP3_FLOAT_OUTPUT/MINIMP3_NO_SIMD configuration) on three streams — the two committed MPEG-2 22.05kHz mono fixtures and a new MPEG-1 44.1kHz joint-stereo 128kbps fixture (stereo.mp3, generated with jump3r/LAME 3.98 from transient-heavy synthetic PCM to force the short-block and mid/side paths). All 763,200 decoded samples were bit-identical across all three streams. Mp3DecoderTest pins that result with SHA-256 digests of the decoded PCM (the decode path is pure IEEE-754 single arithmetic, no libm, so the bits are platform-deterministic) and cross-checks stream structure against JLayer as an independent implementation. Measured in passing: JLayer’s own output differs from minimp3 by ~6 LSB rms (its known limited accuracy) and clamps at 16-bit full scale where the float path legitimately peaks above 1.0 — neither affects us, but don’t be surprised by it if using JLayer as an oracle later.

Decoder behavior notes for the future fingerprinter: output is interleaved [-1, 1] floats (unclamped); a corrupt/garbage buffer never throws — the huffman fast path reads zeros where C would over-read stack garbage (deliberate divergence, same “no crash, nonsense output” contract); frames are skipped (0 samples, frameBytes > 0) until the bit reservoir primes, matching minimp3. The FFT + spectral-peak constellation landed same-day (next section), as did the global-store provenance design (“Shipped: the global acoustic store”).

Acoustic fingerprinter core: FFT + spectral-peak constellation (2026-07-19, additive)

AcousticFingerprinter (internal, common code, plus a small radix-2 Fft) is the level-invariant matching layer itself — extract/match only, deliberately not wired into the download pipeline or any store (that integration is blocked on the global-store provenance design above, and on the field validation below). Pipeline: streaming Mp3Decoder frames → mono downmix → linear resample to 11.025kHz → Hann STFT (1024-sample window, 512 hop ≈ 46ms frames, 0–5.5kHz band) → spectral peaks → landmark hashes (anchor bin, target bin, frame delta packed in 24 bits; fanout 6, ≤63 frames ahead) → matching by per-fingerprint time-offset consensus (a real occurrence stacks votes on one offset ±1 frame; coincidental hash collisions scatter). Floors mirror the byte layer: ≥5s to fingerprint, ≥5s matched span and ≥20 agreeing landmarks to report. Both passes stream — peak memory is bounded by landmark lists, never episode PCM (the 2026-07-05 mobile OOM lesson).

Gain invariance is by construction, not calibration: peak selection uses only frame-relative thresholds — a peak must strictly dominate its ±2-frame ±3-bin neighborhood, clear the frame’s geometric-mean log-power by 1.5 nats, and sit within 12 nats of the frame’s strongest bin. A global gain change moves every quantity together. AcousticFingerprinterTest pins ≥95% identical landmarks between 1.0× and 0.5× gain (measured 98.6%; the shortfall from 100% is float rounding flipping borderline peaks — which is also why acoustic fingerprint ids, unlike byte-layer ids, are only stable for identical decoded PCM, not across encodes or platforms’ libm differences).

Validation (2026-07-19, synthetic only so far): jump3r (the pure-java LAME port that generated stereo.mp3) is now a jvmTest dependency used as an in-test encoder, so the tests re-create the exact serving shapes the layer exists for. Pinned green: a creative fingerprinted from a 128kbps 44.1kHz encode is found (with ~±1s edges) in an episode that embeds the same audio re-encoded at 64kbps 22.05kHz at 0.7× gain — the Simplecast-class per-episode-transcode case byte matching can never touch; the same across a byte-level stitch of three independent encoder runs (the Audioboom shape); both occurrences of one creative rotated through two slots; and no match in a creative-free episode.

Empirical lesson — stationary audio is degenerate (and the committed fixtures proved it): a first test matched single.mp3 against a stitch of the other committed fixtures and got a stronger match on content-a.mp3 than on the actual embedded copy — those fixtures are held test tones, and a held tone collapses the constellation to a handful of distinct hashes that align at many offsets against any similar tone. Two guards came out of this: the dynamic-range bound above (a tone’s quantization-noise floor otherwise sneaks spurious “peaks” past the geometric-mean threshold, mp3-encoded silence measured ~-52dB power below the tone bin) and an extraction floor of ≥48 distinct hashes (a 10s 1kHz tone yields thousands of landmarks but only ~a dozen distinct hashes; the synthetic speech-shaped test audio yields hundreds). Consequence for consumers: jingles/sweepers that are pure sustained tones cannot be acoustically fingerprinted — by design, since their matches would be meaningless.

Open before this layer ships in downloadPodcast: (1) the global-store provenance design (per-feed attribution + feed-scoped pruning, required 2026-07-19); (2) fingerprint serialization (the store codec only handles byte-layer entries); (3) the ear-check rule stands — synthetic-encode validation is not real-feed validation; matched spans on real podcast servings must be ear-verified (playbook step 5) before FINGERPRINT-sourced candidates from this layer get a confidence prior, and thresholds (20 landmarks, 1.5/12 nats) are untested against real speech-over-music beds; (4) cost measurement on mobile — full-episode decode + STFT per match pass is new CPU the byte layer never spent. (Items 1 and 2 landed same-day — next section. Items 3 and 4 remain open; the shipped integration is log-only and its log lines are the measurement instrument for 4.)

Shipped: the global acoustic store (2026-07-19, log-only)

The acoustic layer is wired into downloadPodcast, honoring the store-scoping design requirement above and the ear-check rule:

Open question recorded (2026-07-19): after a feed’s clean serving detaches its attribution, the creative still log-matches in that feed’s future episodes off other feeds’ attributions — harmless while log-only, but before candidates ship this needs a design: per-feed negative evidence (“feed X verified this clean”) that suppresses that feed’s candidates without touching other feeds, rather than mere attribution absence (absence must keep matching — it’s indistinguishable from “feed never encountered it”, the cross-feed payoff case). One semantic already decided (ghackett, 2026-07-19): a later human confirmation in that feed overrides the tombstone — consistent with the evidence hierarchy everywhere else in the library (a human ear deliberately confirming the creative as an ad, after the clean-serving observation, outranks the machine signal; acoustic matching also carries false-positive risk byte matching doesn’t, so the tombstone itself may be wrong). Also still open: real-feed ear verification (blocks any confidence prior) and the threshold validation + cost measurement from the previous section.

The aggressive candidate pass (2026-07-05, additive)

AdBoundaryDetector is a read-only last pass over the final output file that emits AdBoundaryCandidate(timeMs, source, role) markers on DownloadState.Complete — points that might be an ad start/end, for consumers to render as skippable chapter markers.

Why this doesn’t violate the under-cut invariant: the invariant governs bytes removed, and this pass removes none — it cannot touch the file, cannot fail the download (every signal is independently guarded), and runs after the cutter has already done whatever the guards allowed. Because a false positive costs the listener one bogus marker instead of lost show content, the pass is deliberately aggressive where the cutter is deliberately conservative. The two live on opposite sides of the meta-lesson: byte-shaped evidence is good enough to suggest, never to cut.

Signals surfaced (all data the pipeline already computed and previously discarded):

  1. Segment joins (Mp3SegmentParser.scan, first production use): tag-frame stitch boundaries in the output. Dead end #1 stands unchanged — segment structure still classifies nothing; joins are emitted as JOIN candidates, not ad verdicts.
  2. The diff (AdCutter.Result now carries its frame-snapped cut list): applied cuts collapse to single splice-point JOINs mapped into the post-cut timeline (each splice shifts back by the material removed before it); guard-refused (Skipped) ranges — the sticky-fill/garbage-reference cases where the file stays untouched — map directly to START/END pairs. This is the first time the guards’ refusals are visible to consumers at all.
  3. Leaked DAI slots (CleanSourceResolver now returns the parsed m=[…] positions it previously only logged): emitted as JOINs in the host’s original/clean timeline. On the clean-source path that maps 1:1 to the output; on the diff path they’re emitted as-is — the post-cut output approximates the clean timeline, and any drift is an accepted false positive. Slots only exist when the caller passed feed metadata (the resolver never probes without it — no new network requests were added).
  4. Host-written CHAP edges (Id3FrameReader, extracted read-only from Id3ChapterShifter; shifting behavior unchanged): every chapter start plus the final end, read from the output file’s tag after any CHAP shifting, so times are already in the output timeline. Audioboom labels ad slots this way.
  5. Fingerprint matches (2026-07-19, only when the caller passes a fingerprintStore): byte-exact recurrences of stored creatives still present in the output file — the ads that survived every earlier stage (sticky fill that blinded the diff). Emitted as START/END pairs spanning the matched creative. See “Cross-episode creative reuse” for the store’s design and its safeguards; per the ship-log-only rule, matches surface as candidates and never cut.

Hygiene: near-duplicates within one source merge (250ms window, earliest wins); different sources are never merged — two signals agreeing at the same timestamp is corroboration the consumer should see. The list is capped at 64 (a wholesale-disagreeing reference can produce hundreds of Skipped ranges; aggressive ≠ unbounded) and sorted by time.

Confidence model (2026-07-05, additive)

Each candidate carries confidence: Float in 0..1 so consumers can sort or threshold a skip list. The values are uncalibrated priors chosen by evidence semantics, not measured error rates — per the meta-lesson, nothing here has been ear-calibrated, so only the ordering is defensible:

signal base reasoning
FINGERPRINT (human-confirmed) 0.95 byte-exact recurrence of a creative a human ear-verified as an ad
DIFF_CUT applied splice 0.9 the diff proved injected material and the cutter acted
FINGERPRINT (diff-proven) 0.85 byte-exact recurrence of a creative the diff proved injected in an earlier download
DAI_SLOT 0.8 the host’s own ad server placed a slot here
DIFF_CUT guard-refused range 0.65 real byte-diff evidence the guards declined to act on
SEGMENT_BOUNDARY 0.4 an encode join — ads and content assembly alike (dead end 1)
ID3_CHAPTER edge 0.3 usually a content chapter; only sometimes labels an ad slot

Corroboration: candidates from different sources within the 250ms merge window combine as independent evidence — each agreeing candidate’s confidence becomes 1 - Π(1 - cᵢ) (e.g. a segment join confirmed by a DAI slot: 1-(0.6)(0.2) = 0.88). The 64-cap now keeps the highest-confidence candidates rather than the earliest, so a garbage-scale diff can’t crowd out a corroborated marker late in the episode.

Calibration path (open): the priors could be fit against ear-verified maps once enough exist (the Nextlander “First Break”/”Second Break” chapters are obvious anchors — a title-aware chapter signal is a cheap future upgrade). Until then consumers should treat the values as ordinal, and no confidence — including 1.0 — licenses auto-cutting or auto-skipping.

Verification status: unit/e2e coverage only (synthetic fixtures + the MockEngine pipeline tests). Per the meta-lesson, no candidate map has been ear-checked against a real serving yet — consumers get the do-not-auto-cut warning in the API docs, and the first real-feed candidate lists (e.g. Nextlander’s leaked slots at ~24:38 / ~44:42) should be spot-checked by ear before podcast-hacker surfaces markers to users.

2026-07-05 field failure: whole-file read OOMed silently on Android

First consumer deployment (podcast-hacker on Android) surfaced as “candidates work on desktop and on short episodes, but long episodes yield zero, silently”:

MP3-level facts worth keeping

Validation playbook (how to re-validate a change)

  1. Unit fixtures: src/jvmTest/resources/audio/ — short sine-tone encodes (ffmpeg libmp3lame, 16kbps/22050Hz mono), no ID3v2 on the piece files so compositions don’t embed a neighbor’s header. Tests compose copies in code; “markerless” cases strip a piece’s leading tag frame (parser.frames(...).first().endByte).
  2. Real pairs: download the same episode URL twice at different times (or once each with the app’s pinned UA and a player UA purely to obtain a filled copy — never as the reference). Run the cutter; map ground truth by byte-realign walk between filled and clean copies.
  3. Decode check: ffmpeg -f null - over the full output and around every splice; zero errors expected.
  4. Chapter check (Audioboom): CHAP times in the cut output vs the clean serving.
  5. Ear check: before trusting any new ad map or classifier, have a human listen at the proposed cut points. This is the step that caught both dead ends.

Known blind spots & open directions