From 59f69400a86f133d0f77f72a6a8be4a63c98ebfd Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Fri, 4 Sep 2026 20:19:08 -0400 Subject: [PATCH] audit: fix 9 defects from the 2026-09-04 hidden-bug sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these touch the signal path. The 12-month M15 book (2024-03-31..2025-03-30, $0.65, cached Oanda bars) is byte-identical before and after (md5 c59d3e7e685c3c880b614640adfea74b, 375 trades), and signal/indicator logging to logs/signals.db is unchanged — verified end to end: per-TF indicators for all 3 TFs, 14 candles each, MTF and reasons all still persisted. agent_panel: the >=2-take quorum guards the CHAIR's synthesizer spend, but ran BEFORE the macro branch — so the shipped default (verdict_source="macro", which never calls the synthesizer) silently dropped a good macro verdict whenever the other three personas were slow. Quorum moved below the macro path. analyze_signal_log: _forward_return clamped to the last available bar when the horizon had not elapsed, reporting a near-zero "forward move" for every row logged within `hours` of the end of the data — on every run, blended into the hit-rate/mean-move aggregate. Now returns None. analyze_signal_log: persona_scorecard iterated raw rows through an index that maps EVERY member row of an event, so a re-logged scan appended the same event R twice. One vote per event, matching news_outcome_buckets. Measured inflation on the live DB: 1.03x. signal_log: dedup required a non-negative logged_at gap, but logged_at is stamped before the row wins the write lock (busy_timeout 5 s), so two processes logging one setup at a boundary can invert timestamp order against id order and seed a second event. Now abs(gap); same in backfill_seeds. Zero occurrences in the current 366 setup rows. advisory_outcome: the catch-all turned real evaluator faults into the same UNRESOLVABLE used for legitimate weekend gaps, with nothing in the log. Still fail-safe, now logged. llm_client: only the "cap" warn-once flag was ever reset, so in a daemon the second and every later network/http/parse outage logged at DEBUG only. Cleared on UTC-day roll and re-armed by a recovered call. dashboard.js: renderCards() redraws the selector for every timeframe but was gated on the SELECTED one changing, leaving the other buttons stale (up to 4 h on H4) while renderHero's rail already showed the new value. news_summary.php: atomic_write treated a short write (disk full) as success, publishing a truncated body the cache-first branch then served forever; and the daily cap was charged before the no-key check and before the stampede lock re-checked the cache, so a click burst could exhaust the 150/day budget with zero LLM calls. migrate_signal_log_news: logged_at is ISO with 'T' while SQLite's datetime() uses a space, so string-comparing them put the whole cutoff calendar day on the wrong side and it never pruned. NOTE: this now deletes one day more than before — it only runs when invoked by hand. Tests 414 -> 422 (new Module 28, one per fix). The existing "<2 valid takes" test was retargeted at the chair — the case it actually protects — rather than deleted, with new coverage for macro-alone. Not fixed, recorded for the owner: - Gate 6c's boost condition is textually identical to the C-grade definition, so every C in a "transitioning" regime is promoted to B before gate 6e reads it, making sell_min_quality's exclusion of "C" a no-op on M15/H4 (H1 and M5 are immune by other means). The backtester runs the same code, so every pinned book already includes this — changing it is a rule-1 revalidation event. - Safe-haven/geopolitical headlines score exactly 0.00 in the keyword fallback and are now dropped entirely by the ±0.1 neutral filter, contradicting the platform's own gold doctrine. Bites only when DeepSeek is capped/disabled/failing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ah5EB1goi5inQmSd37Ne6a --- advisory_outcome.py | 11 ++ agent_panel.py | 14 +- analyze_signal_log.py | 18 +++ llm_client.py | 14 +- scripts/migrate_signal_log_news.py | 8 +- signal_log.py | 11 +- static/dashboard.js | 11 +- static/news_summary.php | 86 ++++++----- test_all.py | 223 ++++++++++++++++++++++++++++- 9 files changed, 353 insertions(+), 43 deletions(-) diff --git a/advisory_outcome.py b/advisory_outcome.py index 1f6eb76..d4407ab 100644 --- a/advisory_outcome.py +++ b/advisory_outcome.py @@ -8,11 +8,14 @@ but its evidence is the cleaned mid-candle snapshots stored in signals.db. from __future__ import annotations +import logging import math from datetime import datetime, timedelta from typing import Any, Dict, Iterable, List, Optional, Tuple from zoneinfo import ZoneInfo +logger = logging.getLogger(__name__) + MODEL = "stored-closed-mid-next-open-trail-v1" MAX_HOLD_BARS = 300 REFERENCE_COST = 0.30 @@ -331,6 +334,14 @@ def advance_outcome( return state return state except Exception as exc: + # Still fail-safe (never raises into a scan), but no longer + # SILENT: a real defect here used to be indistinguishable from a + # legitimate weekend/downtime gap, discoverable only by grepping + # advisory_outcome_json. Callers persist the state either way. + logger.exception( + "advisory outcome evaluator failed for the %s outcome seeded " + "at %s — recorded UNRESOLVABLE", + state.get("timeframe"), state.get("signal_bar_at")) state["status"] = "UNRESOLVABLE" state["result"] = None state["reason"] = f"outcome evaluator error: {type(exc).__name__}" diff --git a/agent_panel.py b/agent_panel.py index a74f5cf..a8e7640 100644 --- a/agent_panel.py +++ b/agent_panel.py @@ -758,9 +758,8 @@ def run_panel(report: "SignalReport", done, _ = _fwait(futures, timeout=client_timeout + 8.0) takes = [f.result() for f in done if not f.exception()] takes = [t for t in takes if t is not None] - if len(takes) < 2: - logger.debug("agent panel: %d/4 persona takes — skipping synthesizer", - len(takes)) + if not takes: + logger.debug("agent panel: 0/4 persona takes — no verdict") return None order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3} takes.sort(key=lambda t: order.get(t.persona, 9)) @@ -773,6 +772,15 @@ def run_panel(report: "SignalReport", takes=takes, created_ts=time.time(), payload_hash=payload_hash, source="macro") # Macro take missing (timed out / invalid) — fall back to the chair. + # Quorum guards the CHAIR only: it weighs several voices, so one lone + # take is not worth a synthesizer request. The macro path above needs + # only its own take and must never be gated by it — that gate used to + # sit before the macro branch and silently dropped a good macro + # verdict whenever the other three personas were slow. + if len(takes) < 2: + logger.debug("agent panel: %d/4 persona takes — skipping synthesizer", + len(takes)) + return None reply = get_llm_client().chat_json(_SYNTH_SYSTEM, { "direction": direction, "takes": [{"persona": t.persona, "stance": t.stance, diff --git a/analyze_signal_log.py b/analyze_signal_log.py index c5d55f7..fe94128 100644 --- a/analyze_signal_log.py +++ b/analyze_signal_log.py @@ -444,6 +444,14 @@ def _forward_return( """Close-to-close move from `price` to the last close at ts+hours.""" target = pd.Timestamp(ts) + pd.Timedelta(hours=hours) closes = df["Close"] + # The horizon must have ELAPSED. Without this the searchsorted below + # clamps to the last available bar, so every row logged within + # `hours` of the end of the data reported the latest close as its + # "forward" move — a near-zero reading for a trade that has had no + # time to develop, blended into the hit-rate/mean-move aggregate on + # every run. Not-yet-known is None, the same as insufficient history. + if len(df.index) == 0 or df.index[-1] < target: + return None pos = int(df.index.searchsorted(target, side="right")) - 1 if pos < 0: return None @@ -753,6 +761,7 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent], by_row = _row_index(events) con = open_readonly(db_path) agg: Dict[Tuple[str, str], List[float]] = {} + seen_events: set = set() try: for rid, takes in con.execute( "SELECT id, panel_takes_json FROM signals" @@ -760,6 +769,15 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent], ev = by_row.get(rid) if ev is None or ev.r_headline is None: continue + # One vote per EVENT, not per row. A scan re-logged inside the + # cluster window carries its own panel takes, and counting + # both rows appended the SAME event R twice — inflating n and + # the persona means (news_outcome_buckets already iterates + # unique events; this is the same rule). + ekey = ev.rows[0].id if ev.rows else rid + if ekey in seen_events: + continue + seen_events.add(ekey) try: for t in json.loads(takes): agg.setdefault((t.get("persona", "?"), diff --git a/llm_client.py b/llm_client.py index 296b73b..47c025a 100644 --- a/llm_client.py +++ b/llm_client.py @@ -90,7 +90,11 @@ class DeepSeekClient: if self._count_day != today: self._count_day = today self._count = 0 - self._warned.discard("cap") + # Clear ALL warn-once kinds, not just "cap": the network/ + # http_*/parse flags used to be set once and never reset, + # so in a daemon that runs for weeks the second and every + # later outage logged at DEBUG only. + self._warned.clear() if self._count >= self._daily_cap: self._warn_once( "cap", "DeepSeek daily request cap (%d) exhausted — " @@ -149,6 +153,14 @@ class DeepSeekClient: "expected JSON object — ignoring") else: self.last_ok = time.time() + # A recovered call re-arms the failure warnings so the NEXT + # outage is visible at WARNING again. "cap" keeps its own + # UTC-day reset in _under_cap. (Taken under the lock; safe + # here because chat_json holds no lock — _under_cap has + # already returned.) + if self._warned - {"cap"}: + with self._lock: + self._warned = {k for k in self._warned if k == "cap"} return parsed @staticmethod diff --git a/scripts/migrate_signal_log_news.py b/scripts/migrate_signal_log_news.py index 904d11c..4791e30 100644 --- a/scripts/migrate_signal_log_news.py +++ b/scripts/migrate_signal_log_news.py @@ -95,9 +95,15 @@ def prune_orphans(con: sqlite3.Connection) -> int: def prune_news_older_than(con: sqlite3.Connection, days: int) -> int: """Drop the news reference (not the row) from rows older than N days, then delete snapshots nothing references any more.""" + # logged_at is ISO8601 with a 'T' and a +00:00 offset; SQLite's + # datetime() returns 'YYYY-MM-DD HH:MM:SS'. Comparing them as raw + # strings put every row from the cutoff CALENDAR DAY on the wrong + # side ('T' > ' '), so a full day never pruned. Normalise to the + # same shape first — all logged_at values are UTC (see _iso()). cur = con.execute( "UPDATE signals SET headlines_hash=NULL, events_hash=NULL" - " WHERE logged_at < datetime('now', ?)", (f"-{int(days)} days",)) + " WHERE substr(replace(logged_at, 'T', ' '), 1, 19)" + " < datetime('now', ?)", (f"-{int(days)} days",)) con.commit() prune_orphans(con) return cur.rowcount diff --git a/signal_log.py b/signal_log.py index 350e6bf..1f7f0ed 100644 --- a/signal_log.py +++ b/signal_log.py @@ -748,7 +748,14 @@ class SignalLogger: - datetime.fromisoformat(prior[2])).total_seconds() except (TypeError, ValueError): gap = None - if gap is not None and 0 <= gap <= OUTCOME_DEDUP_GAP_SECONDS: + # abs(), not 0 <= gap: logged_at is stamped in _report_row + # BEFORE this row wins the write lock (busy_timeout 5 s), so + # two processes logging the same setup at one boundary can + # land with their timestamp order inverted against their id + # order. A negative gap is that inversion, not a different + # trade — treating it as "not a duplicate" seeded a second + # event and double-counted its R. + if gap is not None and abs(gap) <= OUTCOME_DEDUP_GAP_SECONDS: event_id = int(prior[1] or prior[0]) self._conn.execute( "UPDATE signals SET advisory_event_id=?," @@ -967,7 +974,7 @@ class SignalLogger: continue prior = last_by_key.get((symbol, key)) if (prior is not None - and 0 <= (ts - prior[2]).total_seconds() + and abs((ts - prior[2]).total_seconds()) <= OUTCOME_DEDUP_GAP_SECONDS): # Same setup re-logged inside the gap: a duplicate of # the canonical event regardless of its legacy status diff --git a/static/dashboard.js b/static/dashboard.js index 1ba16c5..838bcc6 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -820,6 +820,7 @@ async function poll() { const priorSnapshot = snapshot; let snapChanged = false; let selectedChanged = false; + let cardsChanged = false; let detailChanged = false; if (snap.text !== lastSnapText) { const incoming = JSON.parse(snap.text); @@ -827,6 +828,14 @@ async function poll() { snapChanged = true; selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !== tfFingerprint(incoming.timeframes?.[selectedTf]); + // renderCards() redraws the selector for EVERY timeframe, so it + // must react to any of them changing — not just the selected one. + // Gating it on selectedChanged left the other buttons showing a + // stale arrow/score (up to 4 h on H4) while renderHero's rail, + // which redraws unconditionally, already showed the new value. + cardsChanged = TF_ORDER.some(tf => + tfFingerprint(priorSnapshot?.timeframes?.[tf]) !== + tfFingerprint(incoming.timeframes?.[tf])); detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !== tfFingerprint(incoming.timeframes?.[detailTf]); snapshot = incoming; @@ -841,7 +850,7 @@ async function poll() { if (snapChanged) { renderHero(); - if (selectedChanged) renderCards(); + if (cardsChanged) renderCards(); if (detailChanged) renderDetail(); if (selectedChanged) { const signal = snapshot.timeframes[selectedTf]?.report?.signal; diff --git a/static/news_summary.php b/static/news_summary.php index 76c8648..00b746f 100644 --- a/static/news_summary.php +++ b/static/news_summary.php @@ -73,7 +73,12 @@ function respond(int $status, array $obj, string $cache = 'no-store'): void function atomic_write(string $path, string $data): bool { $tmp = $path . '.tmp.' . getmypid(); - if (@file_put_contents($tmp, $data) === false) { + $written = @file_put_contents($tmp, $data); + // A SHORT write (disk full / quota) returns a byte count, NOT false. + // Renaming that into place would publish a truncated JSON body which + // the cache-first branch above then serves forever. + if ($written === false || $written !== strlen($data)) { + @unlink($tmp); return false; } if (!@rename($tmp, $path)) { @@ -120,44 +125,14 @@ if ($hit === null) { 'message' => 'Headline is no longer in the feed.']); } -// ── 4. daily cap (fail closed) ────────────────────────────────────── -$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150); -$capFh = @fopen(CAP_FILE, 'c+'); -if ($capFh === false || !flock($capFh, LOCK_EX)) { - respond(503, ['error' => 'cap_io']); -} -$today = gmdate('Y-m-d'); -$cap = json_decode((string)stream_get_contents($capFh), true); -if (!is_array($cap) || ($cap['day'] ?? '') !== $today) { - $cap = ['day' => $today, 'count' => 0]; -} -if ($cap['count'] >= $dailyCap) { - flock($capFh, LOCK_UN); - fclose($capFh); - respond(429, ['error' => 'cap_exceeded']); -} -// Increment BEFORE the LLM call — attempts count against the cap -// (same semantics as llm_client._under_cap). -$cap['count']++; -rewind($capFh); -if (ftruncate($capFh, 0) === false - || fwrite($capFh, json_encode($cap)) === false) { - flock($capFh, LOCK_UN); - fclose($capFh); - respond(503, ['error' => 'cap_io']); -} -fflush($capFh); -flock($capFh, LOCK_UN); -fclose($capFh); - -// ── 5. API key ────────────────────────────────────────────────────── +// ── 4. API key ────────────────────────────────────────────────────── $keyFile = getenv('DEEPSEEK_KEY_FILE') ?: '/etc/gold-dashboard/deepseek.key'; $apiKey = trim((string)@file_get_contents($keyFile)); if ($apiKey === '') { respond(503, ['error' => 'no_key']); } -// ── 6. per-id stampede lock, then re-check the cache ──────────────── +// ── 5. per-id stampede lock, then re-check the cache ──────────────── $lockFh = @fopen(CACHE_DIR . '/.ht_lock_' . $id, 'c'); if ($lockFh !== false) { flock($lockFh, LOCK_EX); // blocks while a concurrent winner generates @@ -172,6 +147,51 @@ if ($lockFh !== false) { exit; } } +$releaseLock = function () use (&$lockFh) { + if ($lockFh !== false) { + @flock($lockFh, LOCK_UN); + @fclose($lockFh); + $lockFh = false; + } +}; + +// ── 6. daily cap (fail closed) ────────────────────────────────────── +// Counted HERE, not before the lock: a burst of clicks on one headline +// makes exactly one DeepSeek call, and the losers are served from the +// re-checked cache above — charging them against the budget (and +// charging a request that then found no key) could exhaust the whole +// 150/day allowance with zero LLM calls made. +$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150); +$capFh = @fopen(CAP_FILE, 'c+'); +if ($capFh === false || !flock($capFh, LOCK_EX)) { + $releaseLock(); + respond(503, ['error' => 'cap_io']); +} +$today = gmdate('Y-m-d'); +$cap = json_decode((string)stream_get_contents($capFh), true); +if (!is_array($cap) || ($cap['day'] ?? '') !== $today) { + $cap = ['day' => $today, 'count' => 0]; +} +if ($cap['count'] >= $dailyCap) { + flock($capFh, LOCK_UN); + fclose($capFh); + $releaseLock(); + respond(429, ['error' => 'cap_exceeded']); +} +// Increment BEFORE the LLM call — attempts count against the cap +// (same semantics as llm_client._under_cap). +$cap['count']++; +rewind($capFh); +if (ftruncate($capFh, 0) === false + || fwrite($capFh, json_encode($cap)) === false) { + flock($capFh, LOCK_UN); + fclose($capFh); + $releaseLock(); + respond(503, ['error' => 'cap_io']); +} +fflush($capFh); +flock($capFh, LOCK_UN); +fclose($capFh); // ── 7. DeepSeek call (exact chat_json body shape) ─────────────────── $userPayload = json_encode([ diff --git a/test_all.py b/test_all.py index 62faf13..e6b34dd 100644 --- a/test_all.py +++ b/test_all.py @@ -6603,12 +6603,12 @@ def _(): assert v("risk", {"stance": "neutral", "confidence": -3, "note": ""}).confidence == 0.0 -@test("agent_panel: <2 valid takes → None without spending synthesizer") +@test("agent_panel: chair with <2 valid takes → None without spending synthesizer") def _(): try: stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None lc._client = stub - assert ap.run_panel(_panel_report()) is None + assert ap.run_panel(_panel_report(), verdict_source="chair") is None assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \ "synthesizer request must not be spent on a failed panel" assert len(stub.calls) == 4 @@ -6617,6 +6617,38 @@ def _(): ap.reset_panel_state() +@test("agent_panel: macro mode yields a verdict from the macro take ALONE") +def _(): + # The >=2-take quorum exists to protect the SYNTHESIZER spend. Macro + # mode never calls the synthesizer and needs only its own take, so a + # slow/failed technical+risk+contrarian must NOT discard a good macro + # verdict (the quorum used to run before the macro branch). + try: + stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None + lc._client = stub + v = ap.run_panel(_panel_report(), verdict_source="macro") + assert v is not None, "macro verdict dropped when alone" + assert v.source == "macro" and v.verdict == "AGREE" + assert len(v.takes) == 1 and v.takes[0].persona == "macro" + assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \ + "macro mode must never spend a synthesizer request" + finally: + lc._client = None + ap.reset_panel_state() + + +@test("agent_panel: zero valid takes → None in both verdict modes") +def _(): + try: + for source in ("macro", "chair"): + lc._client = _PanelStub({}) # every persona → None + assert ap.run_panel(_panel_report(), verdict_source=source) is None, source + ap.reset_panel_state() + finally: + lc._client = None + ap.reset_panel_state() + + @test("agent_panel: synthesizer failure or garbage verdict → None") def _(): try: @@ -9010,6 +9042,193 @@ def _(): pass +print("\n═══ Module 28: 2026-09-04 audit fixes ═══") + + +@test("signal_log: inverted logged_at (write-lock race) still marks DUPLICATE") +def _(): + # logged_at is stamped BEFORE the row wins the SQLite write lock + # (busy_timeout 5 s), so two processes logging the same setup at one + # candle boundary can land with timestamp order inverted against id + # order. That must not be read as a second, independent trade. + import signal_log as slog + real_dt = slog.datetime + stamp = {"v": "2026-08-19T12:00:30+00:00"} + + class _FakeDT: + @staticmethod + def now(tz=None): + return real_dt.fromisoformat(stamp["v"]) + + @staticmethod + def fromisoformat(s): + return real_dt.fromisoformat(s) + + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + try: + lg = slog.SignalLogger(path) + slog.datetime = _FakeDT + r1 = lg.log(_m27_report(), source="telegram", timeframe="5min") + stamp["v"] = "2026-08-19T12:00:00+00:00" # EARLIER, but higher id + r2 = lg.log(_m27_report(), source="dashboard", timeframe="5min") + slog.datetime = real_dt + rows = {r[0]: r[1:] for r in lg._conn.execute( + "SELECT id, advisory_event_id, advisory_outcome_status FROM signals")} + assert rows[r2][1] == "DUPLICATE", ( + f"negative gap is a lock-order inversion, not a new trade: {rows}") + assert rows[r2][0] == r1 + lg.close() + finally: + slog.datetime = real_dt + os.unlink(path) + + +@test("analyze_signal_log: forward return is None until the horizon elapsed") +def _(): + import analyze_signal_log as asl + idx = pd.date_range("2026-08-19T00:00:00Z", periods=24, freq="1h") + df = pd.DataFrame({"Close": [2400.0 + i for i in range(24)]}, index=idx) + ts = idx[-3].to_pydatetime() + assert asl._forward_return(df, ts, 2400.0, 1) is not None, \ + "an elapsed horizon must still measure" + assert asl._forward_return(df, ts, 2400.0, 24) is None, \ + "an unreached horizon must be None, not the latest close" + assert asl._forward_return(df, idx[-1].to_pydatetime(), 2400.0, 1) is None + + +@test("analyze_signal_log: persona scorecard counts each event once, not per row") +def _(): + import sqlite3 + import types as _types + import analyze_signal_log as asl + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + try: + con = sqlite3.connect(path) + con.execute("CREATE TABLE signals (id INTEGER PRIMARY KEY," + " panel_takes_json TEXT)") + takes = json.dumps([{"persona": "macro", "stance": "support", + "confidence": 70, "note": "x"}]) + con.executemany( + "INSERT INTO signals (id, panel_takes_json) VALUES (?,?)", + [(1, takes), (2, takes)]) + con.commit() + con.close() + # ONE event whose two rows both carry panel takes (same scan + # re-logged inside the cluster window). + ev = _types.SimpleNamespace( + rows=[_types.SimpleNamespace(id=1), _types.SimpleNamespace(id=2)], + r_headline=2.0) + out = asl.persona_scorecard([], [ev], path) + assert len(out) == 1, out + assert out[0]["n"] == 1, f"two rows of ONE event must count once: {out}" + assert out[0]["mean_r"] == 2.0 + finally: + os.unlink(path) + + +@test("llm_client: failure warnings re-arm after a recovered call") +def _(): + import requests as _req + import llm_client as _lc + + class _Resp: + status_code = 200 + + def __init__(self, payload): + self._p = payload + + def json(self): + return self._p + + class _Sess: + def __init__(self, seq): + self.seq = list(seq) + + def post(self, *a, **k): + item = self.seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + c = _lc.DeepSeekClient("key") + c._session = _Sess([ + _req.RequestException("boom"), + _Resp({"choices": [{"message": {"content": '{"ok": 1}'}}]}), + ]) + assert c.chat_json("sys", {}) is None + assert "network" in c._warned, "first failure must arm the warn-once flag" + assert c.chat_json("sys", {}) == {"ok": 1} + assert "network" not in c._warned, \ + "a recovered call must re-arm so the NEXT outage logs at WARNING" + + +@test("advisory_outcome: evaluator errors are logged, not silently UNRESOLVABLE") +def _(): + import advisory_outcome as ao + records = [] + + class _H(logging.Handler): + def emit(self, record): + records.append(record) + + handler = _H() + ao.logger.addHandler(handler) + try: + # progress={} makes the evaluator raise KeyError internally. + out = ao.advance_outcome( + {"status": "PENDING", "progress": {}, "setup": {}, "policy": {}, + "signal_bar_at": "2026-08-19T12:00:00+00:00", "timeframe": "1h"}, + []) + finally: + ao.logger.removeHandler(handler) + assert out["status"] == "UNRESOLVABLE", "must stay fail-safe" + assert out["result"] is None + assert records and max(r.levelno for r in records) >= logging.ERROR, \ + "a real evaluator fault must be visible in the log" + + +@test("migrate: prune_news_older_than prunes the cutoff calendar day") +def _(): + import sqlite3 + import importlib.util + spec = importlib.util.spec_from_file_location( + "_mig_test", os.path.join(os.path.dirname(os.path.abspath(__file__)), + "scripts", "migrate_signal_log_news.py")) + mig = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mig) + + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + try: + con = sqlite3.connect(path) + con.execute("CREATE TABLE signals (id INTEGER PRIMARY KEY," + " logged_at TEXT, headlines_hash TEXT, events_hash TEXT)") + con.execute("CREATE TABLE news_snapshots (hash TEXT PRIMARY KEY," + " body TEXT)") + now = datetime.now(timezone.utc) + # Genuinely older than the 30-day cutoff, but on the SAME calendar + # day as it — the case the raw string compare put on the wrong side + # ('T' sorts after ' '). + boundary = (now - timedelta(days=30)).replace( + hour=0, minute=0, second=1, microsecond=0).isoformat() + con.executemany( + "INSERT INTO signals VALUES (?,?,?,?)", + [(1, boundary, "h1", "e1"), (2, now.isoformat(), "h2", "e2")]) + con.executemany("INSERT INTO news_snapshots VALUES (?,?)", + [("h1", "{}"), ("e1", "{}"), ("h2", "{}"), ("e2", "{}")]) + con.commit() + n = mig.prune_news_older_than(con, 30) + assert n == 1, f"the cutoff-day row must prune, got {n}" + kept = con.execute( + "SELECT headlines_hash FROM signals ORDER BY id").fetchall() + assert kept == [(None,), ("h2",)], kept + con.close() + finally: + os.unlink(path) + + # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════