CNT-1 — the go-live gate progress PUBLISHED on thales.report is one ahead of what the gate's own evaluator computes
status: open · raised 2026-08-27 (interactive session, while reviewing PR #115) · class: truth-divergence-between-layers (reporting only; the gate itself is correct) · judgement: YES · effort ~0.25 pd · horizon: live now on the public site; matters most at the December gate
Plain-language summary for an owner reading one paragraph. Three places
count "how far into the clean clock are we." The one that DECIDES —
go_live.py — is right. The two the owner and the public READ are both one
day ahead: the daily digest says forward OOS day 53/60 and gate.json
publishes Clean live history 53 of 126, while the gate's own evaluator
computes 52. Nobody makes a decision off the two that are wrong, so the
consequence today is small — but the site's entire premise is that every
number is authored by the CLI, and this is a published number that disagrees
with the criterion it claims to report. It is also the same marks-vs-returns
confusion the 2026-08-17 weekly audit already had to correct once, when a
hand-count mis-dated the oos-monitor maturity by four days.
Mechanism — verified line-by-line at HEAD 2072f4b
Two counting conventions, three call sites:
| site | counts | value at the 2026-08-26 mark |
|---|---|---|
backtest/oos_monitor.py:185 load_live_returns | returns | 52 |
execution/go_live.py:234 n_live = int(live.size) | returns (calls the loader) | 52 ✅ decides |
execution/digest.py:127 sum(1 for e in equity_hist if e["date"] >= oos_since) | marks | 53 ❌ owner reads daily |
execution/public_export.py:159 "clean_trading_days": len(clean) → gate.json progress.have (:312) | marks | 53 ❌ published |
load_live_returns' own docstring states the cause: "One return is lost at
the boundary (it needs the prior day's equity), which is correct." A daily
return is a difference between two marks, so N marks give N−1 returns. Both
reporting sites count marks with an inclusive >= since filter and label the
result as though it were the gate's unit.
Confirmed empirically, not by inspection alone: the loader returns 52 against
committed state at the 08-26 mark, while momentum.json/gate.json published
clean_trading_days: 53 the same day.
Why the obvious fix is WRONG
Do not subtract one. The gap is not a fixed offset:
load_live_returns(trim_leading_flat=True) also trims leading flat days
(warm-up, not performance), so the marks count is an upper bound on the
returns count, not returns + 1. It happens to be exactly one today because
there are no leading flats after 2026-06-11. Hardcoding -1 would be another
hand-count — precisely the error that produced the 2026-09-04 mis-dating the
08-17 audit had to fix.
The honest fix is that a surface reporting the gate's progress must call the same function the gate calls, or must label its own number as marks and stop calling it gate progress.
Data plan
None — owned committed state plus static code. Reproduce with:
python -c "from thales.backtest.oos_monitor import load_live_returns; \
print(load_live_returns(state_dir='data/processed', since='2026-06-11').size)"
Test design + kill criterion (pre-registered, negative-controlled)
- On a fixture equity history with a known clean-clock boundary, assert the
digest's
live_oos_daysequalsload_live_returns(...).size— deny- asserted (red today). - Same assertion for
public_export.sleeve_stats(...)["clean_trading_days"]as consumed bygate.jsonprogress.have. - The leading-flat negative control, which is the whole point: a fixture
carrying two leading flat days after
sincemust still agree. A-1implementation passes tests 1–2 and fails this one, so the test suite distinguishes the correct fix from the tempting one.
Kill criterion: all three green post-fix; revert the change and tests 1–2 red. If test 3 cannot be made to pass without duplicating the loader's trimming logic, that is itself the finding — it means the surfaces should call the loader rather than reimplement it, which is the preferred fix shape anyway.
Fix shape (~0.25 pd, PROPOSE-only — this row does not implement it)
Preferred: both surfaces call load_live_returns and report its .size, so
there is one counter and it is the one that decides.
Fallback if the digest must stay loader-free (it is observability and must
never import a decision path that could raise): keep the marks count but
rename it — "53 clean marks" is true; "forward OOS day 53/60" is not,
because /60 is the loader's threshold.
Scope note
vrp.json's forward gate day 33/126 is a DIFFERENT clock (the VRP 126-td
forward gate, fleet_digest.py:788) and is not covered by this row; whether
it shares the convention was not checked. Left explicit rather than silently
assumed.
Triage note — 2026-08-31 (annotation only; the body above is untouched)
This row acquires a hard date it does not currently state: 2026-09-04. It was filed as a reporting divergence that "matters most at the December gate". That understates it — the off-by-one produces a false maturity announcement on the owner's only daily push channel, four trading days from now, in the week the 60-day clocks actually mature, during a probable owner absence.
Verified at HEAD 528793d, both counters reproduced against committed state
at the 2026-08-28 mark — 55 marks vs 54 returns:
- decides:
oos_monitor.load_live_returnsandgo_live.pycount returns; - reports:
digest.pyandpublic_export.pycount marks; - published today:
web/public/data/gate.jsoncarriesprogress.have: 55against a true 54.
The dated consequence. The marks counter reaches 60 on 2026-09-04,
while the judge is still four days and one holiday short (the returns counter
reaches 60 on 2026-09-08, independently recomputed this run and matching
NORTHSTAR.md §6 exactly). At that point digest.py's if n_oos < OOS_MIN_DAYS branch flips: the daily email stops saying "verdict pending" and
begins reporting the window as complete. From 09-08 fleet_digest.py prints
the raw counter against a hardcoded /60 with no branch at all, so the
email will read "forward OOS day 61/60", "62/60".
And digest.py carries an active false reassurance about exactly this.
Its import line is annotated # one truth — true of the threshold
(MIN_OOS_DAYS is shared), false of the counter, which is the half that is
wrong. A maintainer reading that comment concludes the divergence cannot exist.
Ranking consequence: this moves from "cosmetic, December" to the cheapest dated item in the queue — 0.25 pd against a consequence nine days out on the channel the owner reads when he is not at his desk.
BUILT — 2026-09-02
Outcome: shipped, the honest fix. One function — oos_monitor.live_returns_from_marks
(the pure core load_live_returns now calls) plus live_return_count — and
every progress surface calls it: the daily digest's breadcrumb, the fleet
email (which also gains a matured branch instead of "61/60" against a
hardcoded /60), and public_export.sleeve_stats / gate.json's
progress.have. No -1, no hand-count: marks minus trimmed leading flats
minus one, computed by the judge's own code.
Route — recorded honestly: built on the owner's direct instruction
(interactive, 2026-09-02, "let's do it" on the post-audit pending list),
NOT via queue/approved/; a human merge.
Kill criterion — met: the lockstep test builds a history with two
leading flats after the clock start (4 marks, 2 returns) and asserts the
site's clean_trading_days equals load_live_returns(...).size; the two
existing marks-count assertions were changed to the judge's unit (2 marks →
1), which is exactly the one-day-ahead defect the row describes. The digest
line that would have announced "60/60" on 2026-09-04 now reads 59/60 that day
and matures on 09-08 with the judge.
Provenance recorded by triage 2026-09-07 (annotation only; the record above is untouched): shipped in PR #141 (b50ae9b, merged 2026-09-03).