Skip to content

Branch #2 Investigation — S39 Inventory Anomaly Root Cause

To: Katja CC: Vesper, Atlas From: Orion Re: Root-cause findings, no code written yet


TL;DR

Primary hypothesis: summarize_paper_run._get_inventory_balance() applies an unfiltered capital-events overlay, while InventoryManager.rebuild() applies a baseline-filtered overlay. Any deposit or withdrawal row in capital_events whose created_at predates the first inventory_ledger row for that asset is double-counted by the dashboard but correctly ignored by the engine. The live DB almost certainly has at least one such pre-baseline row (pre-engine XRP deposit + pre-engine RLUSD deposit), which is what produces the ~$140 phantom.

FLAG-034 didn't fix the bug — it introduced it. FLAG-034 added the overlay to summarize so the dashboard would show TOTAL inventory (matching live engine). It copied the arithmetic of get_capital_delta_total but not the baseline filter. Engine and dashboard now diverge whenever a pre-baseline capital row exists.

Status: I can't access neo_live_stage1.db from my sandbox (the copy here is stale/corrupt). I need Katja to run two SQL queries against the live DB before I cut code — the fix is obvious if pre-baseline rows exist, and wrong if they don't.

Branch #2 scope (subject to verification): keep the fix/summarize-paper-run-capital-overlay name. The fix is to make _get_inventory_balance apply the same baseline filter as get_capital_delta_total — either by calling through to StateManager or by replicating the MIN(created_at) FROM inventory_ledger WHERE asset = ? baseline in the inline SQL.


Scope confirmation

S39 ran in LIVE modeconfig/config_live_stage1.yaml has dry_run: false, db_path: "neo_live_stage1.db", and Phase 7.3 integrity-check work explicitly uses the live DB. So the anomaly is a live-mode accounting divergence, not a paper-mode oddity.

The "Terminal showed 112.56 XRP + 176.03 RLUSD = 337 RLUSD total" figures came from _log_session_summary() (main_loop.py:1988-2012), which calls summarize_paper_run.load_paper_run_summary() and renders its output. Those numbers are exactly what _get_inventory_balance() computes.


Structural bug — baseline filter asymmetry

Engine path (InventoryManager.rebuildStateManager.get_capital_delta_total)

Source: neo_engine/state_manager.py lines 1673-1783.

SELECT COALESCE(SUM(CASE
    WHEN event_type = 'deposit'    THEN amount
    WHEN event_type = 'withdrawal' THEN -amount
    ELSE 0
END), 0.0) AS net_delta
FROM capital_events
WHERE asset = ?
  AND created_at >= ?            -- baseline filter

The baseline is MIN(created_at) FROM inventory_ledger WHERE asset = ?. Any capital event that predates the first ledger row for that asset is excluded — because such an event is "assumed to be already absorbed into the engine's starting balance snapshot (on-chain wallet seed taken at first run)" (FLAG-030 contract, per Atlas ruling 2026-04-17).

Dashboard path (summarize_paper_run._get_inventory_balance)

Source: summarize_paper_run.py lines 73-99 (the FLAG-034 change).

SELECT COALESCE(SUM(CASE
    WHEN event_type='deposit'    THEN amount
    WHEN event_type='withdrawal' THEN -amount
    ELSE 0
END), 0.0) AS o
FROM capital_events WHERE asset = ?
                                    -- no baseline filter

Same arithmetic. No baseline filter. Any pre-baseline deposit or withdrawal row gets counted.

Result

Display balance = ledger.last_new_balance + unfiltered_overlay. Engine balance = ledger.last_new_balance + baseline_filtered_overlay.

Difference = sum of pre-baseline deposits − sum of pre-baseline withdrawals.

On the live DB this difference is ~$140 at S39 end, which is very close to the pre-engine committed capital Atlas recorded in the synthetic-basis row (137.28 RLUSD = 39.27 XRP × 1.3314 + 85.00 RLUSD).


Numerical reconstruction

Assumed wallet state (Katja's NEO overview): - Post-injection wallet: 66.820 XRP + 97.610 RLUSD ≈ $197 total - S39 did 4 buy + 4 sell fills — approximately inventory-neutral, so wallet at S39 shutdown ≈ 66.82 XRP + 97.61 RLUSD (± small spread drift)

Engine arithmetic (baseline-filtered): - fills_only_xrp = ledger.new_balance (last row) — this carries the first-run on-chain seed (≈39.27 XRP) plus all cumulative fill deltas. Matches wallet = 66.82 XRP. - overlay_xrp = 0 (no post-baseline XRP deposit/withdrawal rows; Apr 17 injection was RLUSD-only). - _xrp_balance = 66.82 + 0 = 66.82. ✓ - fills_only_rlusd = ledger.new_balance = 47.61 (85 RLUSD seed + fill deltas ≈ 47.61). - overlay_rlusd = +50 (post-baseline — Apr 17 deposit RLUSD 50). - _rlusd_balance = 47.61 + 50 = 97.61.

Dashboard arithmetic (unfiltered): - If the live DB contains any pre-baseline deposit XRP row for the initial 39.27 XRP seed, overlay_xrp in summarize = 39.27. → Display XRP = 66.82 + 39.27 = 106.09 (reported: 112.56, gap of ~6 XRP unexplained). - If the live DB contains a pre-baseline deposit RLUSD 85 row for the initial RLUSD seed, overlay_rlusd in summarize = 85 + 50 = 135. → Display RLUSD = 47.61 + 135 = 182.61 (reported: 176.03, gap of ~7 RLUSD unexplained). - Phantom total ≈ 106.09 × 1.48 + 182.61 − 197143 RLUSD. Reported phantom ≈ 140 RLUSD. Consistent within rounding.

The small residual gaps (6 XRP, 7 RLUSD) could reflect exact pre-engine deposit amounts that differ slightly from the whiteboard numbers, other small capital_events rows I can't see, a second small injection I'm unaware of, or just price drift between S39 shutdown and when Katja read the wallet. The residual does not invalidate the structural diagnosis — the shape of the bug matches; only the exact pre-baseline rows in the live DB are needed to close the arithmetic.


Verification queries — please run these before I cut code

Run these against neo_live_stage1.db on your terminal. Two queries, small output.

Query 1 — show all capital_events rows with their relation to baseline

WITH baselines AS (
    SELECT 'XRP' AS asset, MIN(created_at) AS first_ledger_ts
    FROM inventory_ledger WHERE asset = 'XRP'
    UNION ALL
    SELECT 'RLUSD', MIN(created_at)
    FROM inventory_ledger WHERE asset = 'RLUSD'
)
SELECT
    ce.created_at,
    ce.event_type,
    ce.asset,
    ce.amount,
    ce.basis_delta_rlusd,
    ce.source_note,
    b.first_ledger_ts,
    CASE
        WHEN b.first_ledger_ts IS NULL THEN 'no-baseline'
        WHEN ce.created_at < b.first_ledger_ts THEN 'PRE-BASELINE'
        ELSE 'post-baseline'
    END AS baseline_status
FROM capital_events ce
JOIN baselines b ON b.asset = ce.asset
ORDER BY ce.asset, ce.created_at;

What I expect to see: at least one row flagged PRE-BASELINE per asset. Likely candidates: a deposit XRP 39.27 @ 1.3314 row and a deposit RLUSD 85 row, plus the basis_commit RLUSD 137.28 row (which is harmless — basis_commit falls to ELSE 0 in both overlays).

If no rows are flagged PRE-BASELINE: my hypothesis is wrong. Move to the secondary hypotheses below.

Query 2 — decompose the displayed 112.56 / 176.03 directly

-- XRP breakdown
SELECT
    (SELECT new_balance FROM inventory_ledger WHERE asset='XRP'
     ORDER BY id DESC LIMIT 1) AS ledger_last_xrp,
    (SELECT COALESCE(SUM(CASE WHEN event_type='deposit' THEN amount
                              WHEN event_type='withdrawal' THEN -amount
                              ELSE 0 END), 0.0)
     FROM capital_events WHERE asset='XRP') AS unfiltered_overlay_xrp,
    (SELECT COALESCE(SUM(CASE WHEN event_type='deposit' THEN amount
                              WHEN event_type='withdrawal' THEN -amount
                              ELSE 0 END), 0.0)
     FROM capital_events
     WHERE asset='XRP'
       AND created_at >= (SELECT MIN(created_at) FROM inventory_ledger WHERE asset='XRP')
    ) AS filtered_overlay_xrp;

-- RLUSD breakdown
SELECT
    (SELECT new_balance FROM inventory_ledger WHERE asset='RLUSD'
     ORDER BY id DESC LIMIT 1) AS ledger_last_rlusd,
    (SELECT COALESCE(SUM(CASE WHEN event_type='deposit' THEN amount
                              WHEN event_type='withdrawal' THEN -amount
                              ELSE 0 END), 0.0)
     FROM capital_events WHERE asset='RLUSD') AS unfiltered_overlay_rlusd,
    (SELECT COALESCE(SUM(CASE WHEN event_type='deposit' THEN amount
                              WHEN event_type='withdrawal' THEN -amount
                              ELSE 0 END), 0.0)
     FROM capital_events
     WHERE asset='RLUSD'
       AND created_at >= (SELECT MIN(created_at) FROM inventory_ledger WHERE asset='RLUSD')
    ) AS filtered_overlay_rlusd;

This tells us everything: - ledger_last_xrp + unfiltered_overlay_xrp must equal 112.56 (or be very close). - ledger_last_xrp + filtered_overlay_xrp is what the ENGINE sees. - The difference between the two overlays is the double-count magnitude.


Secondary hypotheses (if Query 1 shows no PRE-BASELINE rows)

I don't think these are the answer — the arithmetic fit for the primary hypothesis is too clean — but for completeness:

H2 — Double live-seeding. Each time the cold-start needs_seed path in main_loop.py:241-271 executes, it overwrites _xrp_balance with the on-chain fetch and writes a fresh first-ledger row that implicitly carries the new seed. If the ledger was ever truncated or the has_inventory_ledger_entries() guard returned False incorrectly (e.g., after a bad restore-from-backup), the seed could run a second time at an elevated on-chain balance. Would show as a sudden large new_balance jump in inventory_ledger. Inspection: look for a row in inventory_ledger where change is ~0 but new_balance is much larger than the previous row — or a session_id gap where ledger rows vanish.

H3 — Ghost fills (apply_fill called twice per fill). Would also affect WAC (xrp_wac_qty and xrp_wac_cost_total would double). Check: compare COUNT(*) FROM fills WHERE session_id = 38 against COUNT(*) FROM inventory_ledger WHERE fill_id IN (SELECT id FROM fills WHERE session_id = 38) AND asset = 'XRP'. Should be 1:1 per fill per asset (so 8:8 for S39 XRP rows). If it's 2:1 somewhere, apply_fill is running twice.

H4 — Offer reserves. Floated in the NEO overview note. Not credible: XRPL get_wallet_balances returns spendable balance (excluding offer-locked XRPL XRP), and _cancel_live_orders_on_shutdown runs BEFORE the snapshot in _shutdown() in live mode (main_loop.py:710-711), so any reserves should be released before the summary reads the ledger. Also — open-order reserves for a single 15-RLUSD bid + 15-RLUSD ask can't add up to 140 RLUSD of phantom.

H5 — Backup restore inconsistency. S38 DB was restored from a backup after CTRL_CLOSE_EVENT corruption. If the backup was from a state where capital_events had more rows than inventory_ledger reflected, summarize's unfiltered overlay would be inflated. This is a SPECIAL CASE of H1 — the pre-baseline rows are those that pre-date the restored ledger's first row, which might be quite recent. Query 1 will catch this regardless of the cause.


Proposed Branch #2 scope (subject to verification)

Keep the branch name fix/summarize-paper-run-capital-overlay — it's still a summarize-paper-run capital-overlay bug, just not the one FLAG-034 thought it was fixing.

Fix: replace the inline SQL in _get_inventory_balance with a call to StateManager.get_capital_delta_total, or (if we don't want summarize to depend on StateManager) inline the same baseline-filtered SQL. I prefer the StateManager call — single source of truth, and the function signature already exists.

Tests (per Atlas Branch-#2 invariants from the pre-7.3 approval): 1. Seed a DB where capital_events has one PRE-BASELINE deposit XRP 39.27 row and one POST-BASELINE deposit RLUSD 50 row. Seed ledger with a single first row for each asset. Assert _get_inventory_balance("XRP", ...) returns ledger_last_xrp + 0 (pre-baseline excluded), not ledger_last_xrp + 39.27. 2. Assert _get_inventory_balance("RLUSD", ...) returns ledger_last_rlusd + 50 (post-baseline included). 3. Assert equality between summarize's result and InventoryManager.rebuild()'s _xrp_balance / _rlusd_balance on a fixture that exercises both pre- and post-baseline rows. This is Atlas's three-source invariant for Branch #2 ("on-chain = inventory_ledger = capital_events overlay").

Not in this branch: the three-source on-chain comparator Atlas specified as the full invariant. That needs a gateway read at shutdown and is a separate diagnostic (overlaps Branch #6 distance-to-touch territory more than summarize). I'll call this out in the branch scope note and ship it as a follow-up if you want, but including it here would bloat the branch past Atlas's "no bundling" rule.


Vesper's non-blocking nit — my call

On the string literals in run_paper_session.py — I'll fold the cleanup (constants instead of inline strings) into a tiny pre-commit in Branch #2 if you're OK with it, since Branch #2 already touches summarize and the halt taxonomy lives next to run_paper_session. If you'd rather keep Branch #2 strictly scoped, I'll defer it to the audit/config-wiring branch (Branch #5) where it fits naturally. Either way works — your call.


Next step

Run Query 1 + Query 2 against neo_live_stage1.db and paste me the output. If Query 1 shows PRE-BASELINE rows, I cut Branch #2 with the fix above. If it shows none, I loop back to H2–H5 and send updated findings.

— Orion