PLB-1 — the placebo's C5 beta companion reads the FROZEN research store, which by design ends before the live window begins, so it has never computed and never can; a working live-SPY loader already exists three modules away and documents this exact trap
status: open · raised 2026-09-10 (owner-side interactive session, correcting its own record from #157 after the first decision-grade placebo run) · class: truth divergence between layers · judgement: YES · effort ~0.1 pd · horizon: fired at the 2026-09-08 maturity; fires again at every future placebo read, including December's
Plain-language summary for an owner reading one paragraph. The placebo
test asks: did our stock-picking beat 200 random 50-name portfolios? A
registered companion (C5, 2026-08-01) was supposed to repeat that comparison
with the market's own move subtracted, so a rising tide could not flatter
either side. It has never once produced a number. The reason is not a stale
file anybody forgot to refresh: it reads data/raw/SPY.parquet, which is
part of the deliberately frozen owned research store, and that store ends
before the live trading window starts. The two can never overlap — not now,
not in December. Meanwhile the forward OOS monitor computes a perfectly good
SPY comparison over the same window, because it calls a different loader that
refreshes SPY when the on-disk copy is behind.
Mechanism — ground truth at HEAD 82e802e
src/thales/backtest/placebo.py:288-295—load_spy_returns(parquet_dir)readsdata/raw/SPY.parquetand returns{}if the file is absent or unreadable. There is no refresh path.- The owned store is frozen by design (CLAUDE.md, "acquire-once → freeze →
own"): sampled at HEAD, 299 of the first 300 symbols end 2026-06-09,
and
SPY.parquetends at a 2026-06-05 return. The live clean clock starts 2026-06-11. The overlap is empty by construction, so the companion's own guard fires and prints "unavailable" — correctly, and forever. src/thales/execution/digest.py:351-354—_load_spy_aligneddoes the same job and names this exact hazard in its docstring: "Fetches SPY alone if the on-disk parquet is stale (SPY is not in the universe, so nothing else refreshes it). Degrades to {} on any failure." SPY is not a universe member, so the daily fetch never touches it — the trap was understood, written down, and handled in one caller only.src/thales/cli.py:2979routes the oos-monitor through that working loader;cli.py:792routes the placebo through the frozen-store one. On 2026-09-09 the first produced excess-over-SPY Sharpe −2.720 (SPY +1.443, beta 0.41 / corr 0.49 over 60d) while the second reported 0 usable days — same session, same window, same benchmark, two different answers because of two different loaders.
Why it matters
The 2026-09-09 placebo is the first decision-grade read and it stands at the 2nd percentile of random stock-picking. The registration built C5 precisely so that number could be read net of the market's move — the check that separates "the strategy is bad" from "the tape was good and we lagged it." That check has been unavailable at the one reading that mattered, and December's gate reads the same evaluator.
Fix shape (propose-only)
Point the placebo at the loader that works — pass the digest's
_load_spy_aligned behaviour (fetch-if-stale, degrade to {} on failure)
rather than a bare parquet read, or lift it into one shared helper both
callers use. The registration's rule that SPY may never be silently proxied
by another symbol is preserved either way: the fix changes where SPY comes
from, never what it is.
Test design + negative control
Fixture: a live window that starts after the on-disk SPY series ends → the companion must still compute (proving the refresh path is reached), and a fixture where the fetch fails → it must still degrade to the explicit "unavailable" note rather than a proxy or a crash. Negative control, shown in the PR: restore the bare parquet read and assert the first test fails.
Kill criterion (pre-registered)
If the fetch path cannot reach SPY from the environment the evaluator actually runs in (the owner's Mac, per the AUDIT.md register), the item dies and is replaced by a one-line change to the run instruction — fetch SPY before running the placebo — because a companion that needs an undocumented manual step is worse than one that says "unavailable" out loud.
Prior art / relitigation
None: grep -riE "beta.compan|c5|spy" research/queue/ research/RESEARCH_QUEUE.md
returns only a commit hash (6c5da39). This row CORRECTS the record in
#157, which attributed the failure to a stale file — the framing was wrong
and the corrected finding is sharper. Sibling in shape, not in substance, to
research/queue/open/fos-2-fleet-email-missing-streak-split.md: two callers,
two sources, the divergent one silent.
Cap note: open/ was at its pre-registered 12-file cap before this row,
so filing it puts the directory at 13. That is deliberate and disclosed, not
an oversight: Monday's triage owns the mechanical overflow rule and should
adjudicate this row against the incumbents rather than have the filer
pre-empt it.
Triage annotation — 2026-09-28 (annotation only; the body above is untouched)
Still live at HEAD 864da4e, confirmed by reading both loaders side by
side. src/thales/backtest/placebo.py:288-309 (load_spy_returns) resolves
Path(parquet_dir) / "SPY.parquet" at :295, returns {} if absent at
:296-297, and reads it at :299 — no refresh path anywhere in the
function. cli.py:792 still routes the placebo's C5 beta companion through
it. The working loader is untouched three modules away: digest.py:351-354
(_load_spy_aligned), whose docstring still says "Fetches SPY alone if the
on-disk parquet is stale (SPY is not in the universe, so nothing else refreshes
it)", and which oos-monitor uses at cli.py:2979/:2986. Two loaders, two
answers, unchanged.
One number could not be re-grounded here and one structural claim replaces
it. data/raw/ is gitignored and empty in this container, so the body's "SPY
ends 2026-06-05" cannot be re-measured. The structural claim is verifiable and
holds: the owned store is frozen by design and the live clock starts at
config/settings.yaml:423 oos_monitor.since: "2026-06-11", so the overlap is
empty by construction — the companion cannot compute over the live window
whatever the exact end date is.
CORRECTION TO THE ROW'S TEMPO, and it lowers urgency without touching
correctness. The body reads as though this fires on a schedule. It does not.
The only automated invocation is
.github/workflows/monthly-revalidation.yml:183, and that workflow's
schedule is disabled (:8-19, workflow_dispatch only, disabled 2026-06-01);
grep -n placebo scripts/monthly_revalidation.sh returns zero hits, so the
local launchd monthly job does not run the placebo either. There is no cron
that will re-fire this on 2026-10-01. It fires only on a manual owner run or
a manual dispatch — which means the next read that matters is the December
gate, and it will be a hand-run. "Fires at every future placebo read" is true;
"every future read" is manual.
One tension to cite in the eventual PR. scripts/monthly_revalidation.sh:577-587
states the deliberate "OWNED, FROZEN DATA — NO re-fetch" doctrine. The fix as
written (fetch-if-stale for SPY only) is compatible, but if the placebo is
ever folded into that script a SPY fetch re-introduces the one network
dependency the script exists to have removed. Name the doctrine in the PR so a
reviewer does not have to rediscover it.
Blocked on: nothing. Still the cheapest row in the queue at ~0.1 pd — a one-call redirect plus two synthetic fixtures and a negative control — and it gates the reading of the one number (the 2026-09-09 placebo's 2nd percentile) that the December decision leans on. Its kill criterion (the fetch cannot reach SPY from the evaluator's environment) is unlikely to fire: that environment is the owner's networked Mac.
Triage annotation — 2026-10-05 (annotation only; the body above is untouched)
Unchanged at HEAD 502690b, and the diagnosis is confirmed STRUCTURAL rather
than a stale file. src/thales/backtest/placebo.py:288-297 load_spy_returns
still reads Path(parquet_dir) / "SPY.parquet", returns {} when absent, and
has no refresh path; src/thales/cli.py:792 still routes the C5 companion
through it, while the oos-monitor routes through digest._load_spy_aligned
(cli.py:2979, :2986). The decisive new detail: digest.py:351-385 fetches
SPY in memory only (:368 download_ohlcv(..., primary="yfinance"), concat
:373-377, no write back to disk), so data/raw/SPY.parquet can never be
advanced by any running code path. The frozen store cannot heal itself as a
side effect of anything; the overlap stays empty forever. The live clean clock
is still oos_monitor.since: "2026-06-11" (config/settings.yaml:423), so the
stated non-overlap stands.
Its own registered kill criterion has NOT passed: "<15-point divergence
across decision-grade renders by 2027-02-01 → inert, retired"
(placebo.py:283-284).
One implementation nuance for whoever builds it — not an error in the row.
The two loaders are not drop-in: load_spy_returns(parquet_dir) -> {date: return} versus _load_spy_aligned(equity_hist, config) -> {date: close}. A
shared helper has to reconcile closes→returns and a different argument shape.
Still ~0.1 pd, but it is 0.1 pd of real work, not a one-line import swap.
Rank this week: 11 of 12 — and it is the cheapest thing on the whole board.
It survives the cap on a single argument: it is 0.1 pd, blocked on nothing,
needs no owner ruling, no data and no credentials, and it re-fires at every
future placebo read including December's gate, where the raw percentile is
currently the only binding number because this companion has never computed.
Nine weeks of an unused approval gate have cost this row nine weeks for want of
one git mv.
Could not verify in this container: "SPY.parquet ends at a 2026-06-05 return"
and "299 of the first 300 symbols end 2026-06-09" — data/raw/ is gitignored
and empty here, and no placebo artifact is committed under
results/diagnostics/, so the "0 usable days" render cannot be re-checked from
committed state either. The structural argument above does not depend on them.
Triage annotation — 2026-10-19 (annotation only; the body above is untouched)
Unchanged, still the cheapest row on the board, still blocked on nothing.
placebo.py:288-309's load_spy_returns reads
Path(parquet_dir) / "SPY.parquet" at :295, returns {} if absent at
:296-297, and has no refresh path; cli.py:792 still routes the C5 companion
through it. The working loader is untouched three modules away —
execution/digest.py:351 _load_spy_aligned, whose docstring at :352-354
names this exact hazard, fetching at :368 via
download_ohlcv("SPY", …, primary="yfinance") — and it writes nothing back
to disk, which is the structural confirmation the row needs: no running code
path can ever advance data/raw/SPY.parquet, so the frozen store cannot grow
into the live window by itself. oos_monitor uses the working loader
(cli.py:2979-2986).
Not re-measurable here: data/raw/ is empty in this container, so the "SPY
ends 2026-06-05" figure is unverifiable from this session, and
find results -iname '*placebo*' finds nothing committed. The argument does not
depend on either — the store is frozen by design and the clean clock starts
2026-06-11.
The registered kill criterion (placebo.py:283-284, a <15-point divergence by
2027-02-01) has not passed. The 10-05 caveat stands: the fix is not a drop-in,
since the signatures differ (load_spy_returns(parquet_dir) -> {date: return}
vs _load_spy_aligned(equity_hist, config) -> {date: close}).
Live state: ~0.1 pd · blocked on nothing · fires again at the December gate (43 days), which is the fourth consecutive read it will miss.