---
phase: 26-exchange-gap-classification-dismiss
verified: 2026-03-28T01:00:00Z
status: passed
score: 4/4 must-haves verified
re_verification: false
---

# Phase 26: Exchange Gap Classification and Dismiss Verification Report

**Phase Goal:** Kintsugi permanently classifies and dismisses exchange-level TID gaps so they are never retried
**Verified:** 2026-03-28
**Status:** passed
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| #   | Truth                                                                                                                                                             | Status     | Evidence                                                                                                                                                                                                                                                                            |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | When `_repair_aion()` produces 0 bars for a TID range and ALL Parquet dates are checksum-verified, the shard is logged as `result='exchange_gap'` in kintsugi_log | ✓ VERIFIED | `repair.py:441-467` checks `ChecksumRegistry.has_verified()` for each date; sets `_thread_local.is_exchange_gap = True`; `telemetry.py:128-136` emits `result='exchange_gap'`; test `test_exchange_gap_when_all_dates_verified` and `test_log_repair_exchange_gap_result` both pass |
| 2   | When Parquet is NOT checksum-verified but produces 0 bars, the shard is logged as `result='clean'` (normal 30-day dismiss)                                        | ✓ VERIFIED | `repair.py:444-456`: when `has_verified()` returns False, `all_verified` stays False, `_thread_local.is_exchange_gap` stays False; `telemetry.py` falls through to `"clean"` branch; test `test_not_exchange_gap_when_unverified_parquet` passes                                    |
| 3   | Kintsugi shard selection permanently skips shards with prior `result='exchange_gap'` entries (no 30-day expiry)                                                   | ✓ VERIFIED | `discovery.py:497-504`: second query in `_get_dismissed_set()` selects `WHERE result = 'exchange_gap'` with no `INTERVAL` filter; unioned into `dismissed` set; test `test_exchange_gap_query_has_no_time_window` asserts `INTERVAL` not in query SQL                               |
| 4   | Exchange gap classification is auditable via kintsugi_log                                                                                                         | ✓ VERIFIED | `telemetry.py:108-143`: INSERT to `opendeviationbar_cache.kintsugi_log` with `result='exchange_gap'`, symbol, threshold, TID anchors, duration, source preserved                                                                                                                    |

**Score:** 4/4 truths verified

### Required Artifacts

| Artifact                                        | Expected                                                                             | Status     | Details                                                                                                                                                                                  |
| ----------------------------------------------- | ------------------------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `python/opendeviationbar/kintsugi/discovery.py` | `KintsugiResult.is_exchange_gap` field + permanent dismiss in `_get_dismissed_set()` | ✓ VERIFIED | Line 66: `is_exchange_gap: bool = False`; Lines 494-504: permanent exchange_gap SELECT + union into dismissed set                                                                        |
| `python/opendeviationbar/kintsugi/repair.py`    | Exchange gap classification in `_repair_aion()`                                      | ✓ VERIFIED | Lines 384-467: `_thread_local.is_exchange_gap = False` reset at top; ChecksumRegistry check at `trades_df.is_empty()` branch; thread-local propagated to `KintsugiResult` at line 99/128 |
| `python/opendeviationbar/kintsugi/telemetry.py` | `result='exchange_gap'` in `_log_repair()`                                           | ✓ VERIFIED | Lines 128-136: `"exchange_gap" if getattr(result, "is_exchange_gap", False)` checked first before `"healed"` / `"clean"` / `"failed"`                                                    |
| `tests/test_kintsugi_exchange_gap.py`           | Unit tests for exchange gap classification and dismiss                               | ✓ VERIFIED | 6 tests across 3 classes; covers EXGAP-01, EXGAP-02, EXGAP-02 multi-day, DISMISS-01, DISMISS-02                                                                                          |

### Key Link Verification

| From           | To                     | Via                                                         | Status  | Details                                                                                                                                                                          |
| -------------- | ---------------------- | ----------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `repair.py`    | `checksum_registry.py` | `ChecksumRegistry().has_verified()` in `_repair_aion()`     | ✓ WIRED | Line 445: `registry = ChecksumRegistry()`; Line 450: `registry.has_verified(shard.symbol, check_date.isoformat())` — lazy import pattern used to avoid cycles                    |
| `telemetry.py` | `kintsugi_log table`   | `_log_repair()` INSERT with `result='exchange_gap'`         | ✓ WIRED | Lines 108-143: INSERT command includes `result` parameter; `exchange_gap` value emitted when `is_exchange_gap=True`                                                              |
| `discovery.py` | `kintsugi_log table`   | `_get_dismissed_set()` SELECT `WHERE result='exchange_gap'` | ✓ WIRED | Lines 497-504: dedicated `exgap_result` query with `WHERE result = 'exchange_gap'` (no INTERVAL) — result unioned into `dismissed` set returned to `_select_repair_candidates()` |

### Data-Flow Trace (Level 4)

Not applicable — these are classification/persistence modules, not rendering components. The data flow is: `_repair_aion()` → thread-local flag → `repair_shard()` → `KintsugiResult.is_exchange_gap` → `_log_repair()` → ClickHouse INSERT. This chain is fully verified through the key links above and passing tests.

### Behavioral Spot-Checks

| Behavior                                       | Command                                                                                                                                                                 | Result                                                    | Status |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------ |
| All 6 new exchange gap tests pass              | `uv run --python 3.13 pytest tests/test_kintsugi_exchange_gap.py -x -v`                                                                                                 | 6 passed, 0 failed, 1 warning (CH not running — expected) | ✓ PASS |
| All existing kintsugi tests still pass         | `uv run --python 3.13 pytest tests/test_kintsugi_repair.py tests/test_kintsugi_ordering.py tests/test_kintsugi_parquet_trust.py tests/test_kintsugi_exchange_gap.py -v` | 22 passed, 9 skipped, 1 warning                           | ✓ PASS |
| `KintsugiResult.is_exchange_gap` field exists  | `grep -q "is_exchange_gap" discovery.py`                                                                                                                                | Match at line 66                                          | ✓ PASS |
| `has_verified` called in repair.py             | `grep -q "has_verified" repair.py`                                                                                                                                      | Match at line 450                                         | ✓ PASS |
| `exchange_gap` in telemetry.py                 | `grep -q "exchange_gap" telemetry.py`                                                                                                                                   | Match at lines 129                                        | ✓ PASS |
| Permanent dismiss query exists in discovery.py | Query at lines 497-501 with no INTERVAL filter                                                                                                                          | Present — `WHERE result = 'exchange_gap'` only            | ✓ PASS |

Note: The PLAN's acceptance criterion `grep "exchange_gap" discovery.py | grep -q "SELECT"` technically fails because `SELECT` and `exchange_gap` are on separate lines in the multi-line string. The underlying code is correct — `SELECT DISTINCT ... WHERE result = 'exchange_gap'` is properly implemented across lines 498-500.

### Requirements Coverage

| Requirement | Source Plan   | Description                                                                                                                               | Status      | Evidence                                                                                                                                                              |
| ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| EXGAP-01    | 26-01-PLAN.md | `_repair_aion()` marks `is_exchange_gap` when checksum-validated Parquet repair produces 0 bars for a TID range                           | ✓ SATISFIED | `repair.py:441-467` + test `test_exchange_gap_when_all_dates_verified`                                                                                                |
| EXGAP-02    | 26-01-PLAN.md | Exchange gap classification requires (a) Parquet source is checksum-verified in `ChecksumRegistry`, (b) TID range filter returns 0 trades | ✓ SATISFIED | Both conditions enforced: `parquet_available=True` guard at line 444 + `registry.has_verified()` loop; tests cover unverified and partial-verification negative cases |
| DISMISS-01  | 26-01-PLAN.md | Kintsugi's shard dismiss filter permanently skips shards classified as exchange gaps (no 30-day expiry)                                   | ✓ SATISFIED | `_get_dismissed_set()` second query has no `INTERVAL` filter; `test_exchange_gap_query_has_no_time_window` asserts absence of `INTERVAL`                              |
| DISMISS-02  | 26-01-PLAN.md | `kintsugi_log` records exchange gap classifications with `result='exchange_gap'` for auditability                                         | ✓ SATISFIED | `_log_repair()` inserts `result='exchange_gap'`; `test_log_repair_exchange_gap_result` verifies via mock                                                              |

**Orphaned requirements check:** REQUIREMENTS.md also declares `STATHERA-01`, `STATHERA-02` (Phase 27), and `RETRO-02` (Phase 28) in the same section. These are not assigned to Phase 26 in either the plan or REQUIREMENTS.md — they are out-of-scope for this phase and correctly not claimed.

### Anti-Patterns Found

| File           | Line | Pattern                                                                                | Severity | Impact                                                                 |
| -------------- | ---- | -------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------- |
| `discovery.py` | 625  | Log message says "0-bar in last 30d" but now also covers permanent exchange_gap shards | ℹ️ Info  | Misleading log only — dismissed set correctly includes both categories |

No stubs, no TODO/FIXME, no empty implementations found in the four modified files.

### Human Verification Required

None. All behaviors are verifiable programmatically via unit tests and code inspection. The kintsugi_log ClickHouse write is integration-tested at the mock layer; actual ClickHouse INSERT verification would require the remote server to be running (not available in this environment) but the query construction is fully exercised.

### Gaps Summary

No gaps. All four observable truths are verified. All artifacts exist, are substantive, and are wired. Key links are confirmed. Tests pass (6 new + 22 existing kintsugi tests). Requirements EXGAP-01, EXGAP-02, DISMISS-01, DISMISS-02 are all satisfied.

The one non-critical observation: the PLAN's grep-pipe acceptance criterion `grep "exchange_gap" discovery.py | grep -q "SELECT"` fails as a shell command because the multi-line string has `SELECT` on one line and `exchange_gap` on another. The underlying implementation is correct.

---

_Verified: 2026-03-28_
_Verifier: Claude (gsd-verifier)_
