Skip to content

Read-Cutover Job Queries (Neo4j ↔ Neptune)

Read-Cutover Job Queries (Neo4j ↔ Neptune)

Section titled “Read-Cutover Job Queries (Neo4j ↔ Neptune)”

⚠️ Partially historical (PLT-853, 2026-07). The precompute subsystem (Y6–Y9 aggregates, GlobalAgg, global counters) and the DUE_NOW index (day-bucket reader, retry consumer, prune, catch-up singleton) were removed — they were dead (nothing read precompute output; the repurchase read uses the disjoint-shard scan, not the DUE index). Sections below that describe a precompute dependency or DUE-index read/write are no longer accurate; the repurchase Neptune read is the scan (GetAllRepurchaseCandidatesNeptune) with no precompute/DUE gate. Kept for historical context.

Scope. The scheduled read jobs whose Neo4j→Neptune cutover is gated by PLT-853, with the production Neo4j and Neptune queries side by side, the condition each needs to run (precompute dependency, query-speed posture), and the scheduled time. Companion to the result-consistency doc ~/src/doc/consumer-graph-worker/plt853-read-cutover-readiness.md (kept out-of-repo) and plt853-due-index-investigation.md.

As of main @ aaaa9f8 (2026-06-28). Queries are quoted from the embedded cyphers / Go readers; verify against the cited file before relying on exact text — the read paths are under active change.

TL;DR — what cuts over, and what gates it

Section titled “TL;DR — what cuts over, and what gates it”
JobReadsNeo4j (prod today)Neptune (target)Precompute needed?Scheduled
Repurchase nudgebase graph (PURCHASED edges)reads_repurchase.go cursor batchfull scan repurchase_candidates_neptune.cypher (ADOPTED #292 — the Neptune read); DUE-index due_read.cypher (present, dark — fallback)NoDaily 03:00 UTC (SchedulerRunHour=3)
Weekly forecast (main shelf)base graph + denormalized scalarsreads_forecast.go GetForecastShelfRowsForUsersdoes not exist yet (write-path scalars prepped by #255/#264)No (uses per-edge scalars)Daily 03:00 UTC sweep; per-user send personalized to local weekly slot
Weekly forecast (cold-start)current: user’s own relaxed edges. intended (a): + GlobalAggreads_forecast.go GetColdStartShelfRowsdoes not exist yetDesign-dependent — see ⚠️same as forecast

⚠️ CORRECTION — precompute aggregates are currently UNREAD. Verified repo-wide on main @ aaaa9f8: nothing reads GlobalAgg.top_products_csv or the u.top_*_csv/u.*_count aggregates (only precompute writers + comments reference them). The forecast cold-start (GetColdStartShelfRows) reads the user’s own relaxed PURCHASED edges (r.times>=1 + allowedLeaf allowlist), not GlobalAgg. GlobalAgg-backed cold-start is the intended design (runner.go: “Phase-3 precompute-pattern reads consume”; guide §3.3.2/§9.1), unimplemented on both engines. So whether precompute gates the forecast cutover is a branch-#3 choice: (a) build Neptune cold-start to read GlobalAgg → precompute critical; (b) port the current relaxed query → no precompute dependency. Rows/§2b below marked “GlobalAgg” describe design (a), not shipped code.

The cutover-blocking gates are correctness/wiring, not speed (speed is solved — see the readiness doc’s ⭐ summary):

  • GAP-10: repurchase Neo4j-vs-Neptune candidate-set parity not yet diffed.
  • Forecast has no Neptune read implemented yet; cold-start parity depends on prod precompute being enabled (currently DARK).
  • Precompute scheduled full pass is ModeFull, not reconcile (reconcile is manual-only) — see §3.

When: daily at 03:00 UTC (cmd/unified-worker/config.go:SchedulerRunHour, prod run_hour: 3). Once-a-day ⇒ candidate set only needs ≤24h freshness — central to the scan-vs-index debate below. Read target flag: repurchase_read_target (internal/notification/repurchase/reader.go:NewCandidateReader); default/prod = neo4j. Precompute dependency: none — reads live PURCHASED edge properties directly.

1a. Neo4j — production (pkg/client/reads_repurchase.go::GetRepurchaseCandidatesBatch)

Section titled “1a. Neo4j — production (pkg/client/reads_repurchase.go::GetRepurchaseCandidatesBatch)”

Cursor-paginated over all users (BatchSize=5000); Gift-Cards leaf exclusion applied post-pagination in reader.go (PLT-536, avoids index flip).

MATCH (u:User)-[r:PURCHASED]->(p:Product)
WHERE r.times >= 2
AND u.user_id >= $cursor
AND p.category_hierarchy IS NOT NULL
AND size(p.category_hierarchy) > 0
AND NOT p.category_hierarchy[0] IN $blockedTier1CategoryIDs
AND r.last >= datetime() - duration({days: $lookbackDays})
AND r.avg_interval_days > 0
AND duration.inDays(r.last, datetime()).days >= toInteger(r.avg_interval_days * $skipRecentThreshold)
AND reduce(earliest = r.timestamps[0], ts IN r.timestamps | CASE WHEN ts < earliest THEN ts ELSE earliest END) <= datetime() - duration({days: $minHistoryDays})
AND r.last + duration({days: toInteger(r.avg_interval_days)}) >= datetime() + duration({hours: $leadHours})
AND r.last + duration({days: toInteger(r.avg_interval_days)}) < datetime() + duration({hours: $leadHours * 2})
RETURN u.user_id AS userID, u.timezone AS timezone,
p.product_id AS productID, p.name AS productName, p.brand AS brand, p.category AS category,
p.category_hierarchy AS categoryHierarchy, r.times AS purchaseCount,
r.avg_interval_days AS avgIntervalDays, r.last AS lastPurchaseDate, r.timestamps AS timestamps
ORDER BY u.user_id, r.last DESC
LIMIT $limit

1b. Neptune — repurchase read (RESOLVED by #292: full scan; DUE-index kept dark as fallback)

Section titled “1b. Neptune — repurchase read (RESOLVED by #292: full scan; DUE-index kept dark as fallback)”

RESOLVED (#292). The repurchase Neptune read is the full scan (Option B) — restored + wired as the repurchase_read_target=neptune reader. The DUE-index read (Option A) remains in main but is dark/unused, retained only as a warm fallback (see §“What stays running”). Both designs are documented below for reference.

Option A — DUE-index read (present in main, DARK/UNUSED — retained as fallback, NOT the active read). pkg/client/cypher/due_read.cypher, still callable via reads_repurchase_neptune.go. Touches only the DueDay buckets in the due window, then re-validates each :DUE edge against the live PURCHASED edge (the index is an over-approximation; stale edges are filtered, never mis-nudge). Composite (user_id, product_id) cursor; LIMIT applied after re-validation.

MATCH (d:DueDay) WHERE d.`~id` IN $dueDayIds
MATCH (d)-[due:DUE]->(u:User)
WHERE u.user_id > $curUser OR (u.user_id = $curUser AND due.product_id > $curProduct)
MATCH (u)-[r:PURCHASED]->(p:Product) WHERE p.product_id = due.product_id
AND r.times >= 2 AND r.avg_interval_days > 0
AND p.category_hierarchy_csv IS NOT NULL AND p.category_hierarchy_csv <> ''
WITH u, due, p, r, split(p.category_hierarchy_csv, ',') AS cats
WHERE NOT cats[0] IN $blockedTier1
AND NOT cats[size(cats)-1] IN $globalBlockedLeaf // Gift Cards; Neptune has no negative indexing
AND r.last_event_epoch_ms >= $lookbackFloorMs AND r.first_event_epoch_ms <= $minHistoryCeilMs
AND ($nowMs - r.last_event_epoch_ms) >= toInteger(r.avg_interval_days * $skipThreshold) * 86400000
AND (r.last_event_epoch_ms + toInteger(r.avg_interval_days)*86400000) >= $dueFloorMs
AND (r.last_event_epoch_ms + toInteger(r.avg_interval_days)*86400000) < $dueCeilMs
WITH u, due, p, r ORDER BY u.user_id ASC, due.product_id ASC LIMIT $pageSize
RETURN u.user_id AS userID, u.timezone AS timezone,
p.product_id AS productID, p.name AS productName, p.brand AS brand, p.category AS category,
p.category_hierarchy_csv AS categoryHierarchyCsv, r.times AS purchaseCount,
r.avg_interval_days AS avgIntervalDays, r.last_event_epoch_ms AS lastPurchaseEpochMs,
r.timestamps_csv AS timestampsCsv
  • Cost: the read is cheap, but it requires the DUE-write maintenance apparatusUpdateProductDueBucket per receipt (PLT-903), a singleton:true DUE-writer (shared DueDay nodes ⇒ CME ⇒ forced single writer), a backfill Kafka topic + resumable enqueuer (cmd/due-backfill-enqueuer), prune, and the async shadow. That live-write path is where nearly every migration incident originated (#260 shadow-drop, the 92–98% writer saturation sweep before PLT-903).

Option B — full scan (ADOPTED — the active Neptune repurchase read as of #292). repurchase_candidates_neptune.cypher (restored from commit 7a61ce7); concurrency=6, userBatch≤1000 (OOMs at 2000). Scans every user every run — ~32 min @ 151K = parity with today’s prod Neo4j cadence, ~3.5h @ the 1M target. No index, no DUE-write machinery. Returns the full category_hierarchy_csv + timestamps_csv (parity with the Neo4j read).

  • Why it’s attractive: the read is daily (≤24h freshness), so the index’s sub-second-freshness maintenance — and all its incident surface — is over-built for the need. Cutting over on the scan lets us flip reads without running/maintaining the DUE index.

DECISION (RESOLVED by #292). Adopted Option B (scan): the Neptune repurchase read is the scan (restored from 7a61ce7), wired behind repurchase_read_target. The DUE-index read + its write apparatus stay in main but dark — kept as a warm fallback, not retired in this PR (the config gate still requires due_now.enabled=true, and write_target=both keeps the DUE buckets current so the fallback stays viable). Loosening the gate + removing the DUE-write machinery is a gated follow-up (see §“What stays running”), after the scan soaks in prod.


When: evaluated in the daily 03:00 UTC sweep (weeklyforecast handler ShouldRun()==true every day); each user’s actual send is personalized to a local weekly slot (send_hour_local: 8), deduped per ISO week in Valkey (forecastWeeklyMarkTTL=8d). Prod config weekly_forecast.enabled: true, shelf_size:10, user_batch_size:500 (~9s/500-user batch). Read target: Neo4j only — no forecast_read_target flag and no Neptune forecast read exists yet (PR #264: “there’s no Neptune forecast read yet”). #255 + #264 prepped the Neptune write path so a future Neptune read can use denormalized scalars instead of Neptune-unsupported reduce()/array ops.

2a. Main shelf — Neo4j (pkg/client/reads_forecast.go::GetForecastShelfRowsForUsers)

Section titled “2a. Main shelf — Neo4j (pkg/client/reads_forecast.go::GetForecastShelfRowsForUsers)”

User-anchored (MATCH users → CALL{} per-user traversal), likelihood-ranked top-K per user. The #255/#264 optimization denormalizes Product.category_tier1/category_leaf (+ indexes) and PURCHASED.first_day_int/last_day_int/predicted_shopping_day_int to kill the per-edge reduce()/duration() compute (was the ~45 min / 150K candidate-fetch timeout); reads use coalesce(scalar, <old expr>) so un-backfilled data still works.

MATCH (u:User) WHERE u.user_id IN $userIDs
CALL {
WITH u
MATCH (u)-[r:PURCHASED]->(p:Product)
WHERE r.times >= 2
AND p.category_hierarchy IS NOT NULL AND size(p.category_hierarchy) > 0
AND NOT p.category_hierarchy[0] IN $blockedTier1CategoryIDs
AND NOT p.category_hierarchy[-1] IN $globalBlockedLeafIDs
AND NOT p.category_hierarchy[-1] IN $weeklyForecastBlockedLeafIDs
AND r.last >= datetime() - duration({days: $lookbackDays})
AND r.avg_interval_days > 0
AND reduce(earliest = r.timestamps[0], ts IN r.timestamps | CASE WHEN ts < earliest THEN ts ELSE earliest END) <= datetime() - duration({days: $minHistoryDays})
AND r.last + duration({days: toInteger(r.avg_interval_days)}) <= datetime() + duration({days: $windowDays})
AND r.last + duration({days: toInteger(r.avg_interval_days)}) >= datetime() - duration({days: $overdueGraceDays})
WITH r, p, 1.0 / (1.0 + (toFloat(duration.inDays(r.last, datetime()).days)) / (r.avg_interval_days + 1)) AS likelihood
ORDER BY likelihood DESC LIMIT $kPerUser
RETURN r, p, likelihood
}
RETURN u.user_id AS userID, u.timezone AS timezone, u.zip_code AS zipCode,
p.product_id AS productID, p.name AS productName, p.brand AS brand, p.category AS category,
p.category_hierarchy AS categoryHierarchy, r.times AS purchaseCount,
r.avg_interval_days AS avgIntervalDays, r.last AS lastPurchaseDate, r.timestamps AS timestamps,
likelihood AS likelihood
ORDER BY userID, likelihood DESC
  • Neptune: not implemented. When ported, it must read the *_day_int scalars (#264 populates them on the Neptune PURCHASED write path) — Neptune has no array properties so the reduce(... r.timestamps[0] ...) fallback is impossible.

2b. Cold-start — Neo4j (reads_forecast.go::IsColdStart + GetColdStartShelfRows)

Section titled “2b. Cold-start — Neo4j (reads_forecast.go::IsColdStart + GetColdStartShelfRows)”

Triggered when purchaseRels < cold_start_max_purchases (4) OR historyDays < cold_start_min_history_days (21). Current shipped code reads only the user’s own relaxed PURCHASED edges (r.times>=1 + $allowedLeafCategoryIDs) — it does NOT read GlobalAgg. The global-popularity (GlobalAgg) fallback is the intended design (see ⚠️ at top), unimplemented on both engines.

-- IsColdStart
MATCH (u:User {user_id: $userID})-[r:PURCHASED]->()
WITH count(r) AS purchaseRels,
reduce(earliest = datetime(), ts IN collect(r.timestamps) |
CASE WHEN size(ts)>0 AND ts[0] < earliest THEN ts[0] ELSE earliest END) AS earliest
RETURN purchaseRels, duration.inDays(earliest, datetime()).days AS historyDays
  • Precompute dependency (design-dependent): current code has none — cold-start reads the user’s own edges. Intended design (a) would have it read GlobalAgg.top_products_csv, making precompute a prerequisite. Pick the design in branch #3 (see ⚠️ at top).

3. Precompute — which mode feeds which read

Section titled “3. Precompute — which mode feeds which read”

Maintains the Y6–Y9 aggregate columns. Singleton service (consumer-graph-worker-precompute, min=max=1). Prod DARK (PRECOMPUTE_ENABLED=false); stage enabled against 1M idemtest.

Scheduled times (cmd/unified-worker/precompute_component.go)

Section titled “Scheduled times (cmd/unified-worker/precompute_component.go)”
  • Incremental — daily, default 02:00 UTC (PrecomputeIncrementalHourUTC=2).
  • Full — weekly, default Sunday 03:00 UTC (PrecomputeFullWeekday=Sunday, PrecomputeFullHourUTC=3; dueTier lets full win ties).
  • Reconcile — manual only (POST /admin/precompute {"mode":"reconcile"}); does not advance the incremental watermark.

WIRING GAP (flag). Validated decision (readiness doc): full/weekly pass should use reconcile (read-agg, no per-user fold → no CME, ~4.7× faster at 1M: full 1M in 7h01m, errors=0, CME-free vs ModeFull ~45% after 15h). But the scheduled weekly pass is ModeFull, and reconcile is manual-only. To implement the decision, either schedule reconcile for the weekly tier or make the weekly tier call reconcile + tune workers (low, e.g. 8, to coexist with live dual-write — repurpose PR #290). Daily stays ModeIncremental.

Mode → cyphers (internal/precompute/runner.go::Run)

Section titled “Mode → cyphers (internal/precompute/runner.go::Run)”
ModePer-user PrecomputeUserPer-user global fold UpdateGlobalCountersForUserEnd-of-pass global
Incremental (daily)✅ (changed users only, last_edge_change_ms > since)precompute_global_counter_update.cypherComputeGlobalTopK (precompute_global_topk.cypherprecompute_global_write.cypher)
Full (weekly)✅ (all users)ComputeGlobalTopK
Reconcile (manual)✅ (all users)❌ skippedPrecomputeGlobal full-scan read-agg (precompute_global_shard.cypher, 256 hex shards × conc 8) → precompute_global_write.cypher

The per-user fold writes the shared per-product counters (Product.total_times/buyers_count) → CME under concurrency; reconcile avoids it entirely by recomputing global top-K from a read-aggregation. This is the crux of the full-pass = reconcile decision.

Aggregate columnWritten by (cypher)Consumed by
u.top_repurchases_csv (Y6)precompute_user.cypherreference/audit — not read by the repurchase batch (it reads live PURCHASED edges)
u.purchase_count / retailer_count / community_count / category_count (Y7)precompute_user.cypherCoach scorer (purchase_count, category_count); forecast diversity signals
u.top_categories_csv (Y8)precompute_user.cypherreference — cold-start reads live edges, not this CSV
GlobalAgg.top_products_csv (Y9)precompute_global_write.cypher (fed by ComputeGlobalTopK incr/full, or PrecomputeGlobal reconcile)No current reader (verified repo-wide). Intended (design a): weekly-forecast cold-start fallback shelf
Product.total_times / buyers_count (counters)precompute_global_counter_update.cypher (incr/full fold)input to precompute_global_topk.cypher

Key takeaway for the cutover: the repurchase read has no precompute dependency (live-edge read). The forecast read has no precompute dependency in current shipped code either — both main shelf and cold-start read PURCHASED edges, and nothing reads GlobalAgg/aggregates today. Precompute gates forecast only if branch #3 implements the intended GlobalAgg-backed cold-start (design a). So today, precompute readiness gates neither read cutover; it becomes a forecast gate only under design (a).


4. Precompute subsystem — status & cleanup posture (2026-06-28)

Section titled “4. Precompute subsystem — status & cleanup posture (2026-06-28)”

The precompute code lives in three places — internal/precompute/runner.go (orchestrator: modes + worker pool), cmd/unified-worker/precompute_component.go (singleton service + scheduler), and pkg/client/precompute.go + cypher/precompute_*.cypher (Neptune methods/queries). It’s doing three distinct jobs that are easy to conflate, and on main @ aaaa9f8 none of them is on a live read path:

  1. Y6–Y9 aggregate jobbuilt, validated, completely UNCONSUMED. Maintains u.top_repurchases_csv (Y6), u.{purchase,retailer,community,category}_count (Y7), u.top_categories_csv (Y8), GlobalAgg.top_products_csv (Y9), + the Product.total_times/buyers_count counters feeding Y9. Dark (prod precompute_enabled: 'false'; stage 'true' for the 1M tests). No read path consumes any of these columns (verified repo-wide: no GlobalAgg reader; Coach’s purchase_count = r.times, a per-edge value, not u.purchase_count). It’s substrate for the deferred GlobalAgg-backed forecast cold-start (design (a)).
  2. DUE-bucket pruning (PruneDueDays)part of the DUE-index apparatus, not the aggregates. Run as a separate DuePrune scheduled task on the same singleton; deletes past DueDay buckets (dayInt < today). It serves the DUE-index repurchase read (due_read.cypher) — i.e. Option-A repurchase machinery. (The heavier DUE rebucket maintenance moved to event-driven per-receipt writes in PLT-903; the sweep was deleted — runner.go:41. Only the prune remained.)
  3. BackfillForecastDayIntsnot the precompute job. Filed in precompute.go (hence the confusion) but it’s #264’s standalone one-time day-int backfill, run by its own binary cmd/backfill-forecast-dayints. Not invoked by the scheduled precompute job.

One-liner: precompute is a complete, working, but currently dark/unconsumed subsystem — aggregate job (1) writes columns nothing reads, prune task (2) serves a DUE index we’re choosing not to use under (b), (3) is a misfiled backfill helper. Under the (b) cutover none of it is on the critical path.

Under the (b) decision (repurchase = scan, DUE index unused), the DUE-index apparatus becomes cleanup-eligible: PruneDueDays/the DuePrune scheduled task, due_read.cypher, the DUE-writer singleton + backfill enqueuer (cmd/due-backfill-enqueuer) + the live per-receipt UpdateProductDueBucket write path. But do NOT remove it as part of the cutover. Gate the removal on:

  • (i) the scan cutover is validated in prod (GAP-10 candidate-set parity green) and soaked (≥7 days), and
  • (ii) an explicit decision that we won’t fall back to the DUE index (the investigation kept it as the fallback if scan latency ever regresses).

Until then it stays dark-but-present = cheap insurance. Track removal as a separate follow-on PR, not folded into #1/#2/#3.

Keep (do NOT clean up): the Y6–Y9 aggregate job (job 1) — it’s the deliberate substrate for the deferred design-(a) GlobalAgg cold-start; branch #2 (reconcile-weekly, dark) refines its full-pass mechanism for that future enablement. BackfillForecastDayInts (job 3) is harmless/vestigial once day-ints are backfilled; clean up opportunistically, not urgently.

5. Read-cutover validation findings (2026-06-28, prod)

Section titled “5. Read-cutover validation findings (2026-06-28, prod)”

End-to-end validation on prod (in-VPC runner → prod Neptune reader + Neo4j). Three outcomes: speed solved, repurchase candidate parity root-caused to eReceipts, and the data divergence resolved as “Neptune clean / Neo4j contaminated.”

5.1 Speed — SOLVED (concurrent “03:00 scheduler” test)

Section titled “5.1 Speed — SOLVED (concurrent “03:00 scheduler” test)”

Ran the repurchase scan and a weekly-forecast read concurrently on the prod 4xl reader (the realistic nightly situation):

  • Forecast e2e: 51.6 min (152,239 users, errs=0) — read-shape probe (range-walk + per-user top-10 + inline epoch arithmetic; the real Neptune forecast read is branch #3, unbuilt).
  • Repurchase scan: 41.7 min under concurrent load (vs 22.5 min solo), 35,484 candidates, errs=0.
  • Reader peaked ~73% (declined as forecast finished); the live writer (reader-1) stayed ~1% — reads hit the consumer-graph-prod-writer reader replica; live dual-write is untouched. Both jobs inside the 3h handler window.
  • Conclusion: combined read load is feasible on the 4xl reader. Before any 4xl→8xl upsize, tune read concurrency / stagger the jobs (the precompute workers lesson). #264 day-int scalars are NOT needed for speed (bottleneck is traversal + row-volume + sort, not arithmetic).

5.2 Repurchase candidate parity — 12.8% recall, root-caused to eReceipts

Section titled “5.2 Repurchase candidate parity — 12.8% recall, root-caused to eReceipts”

Neo4j vs Neptune repurchase candidates, identical params (lookback=minHistory=90, skip=0.8, lead=24h, tier1+gift-card blocklists), sample user_id range 60*:

value
Neo4j candidates2,419
Neptune candidates2,394
Intersection310
Neo4j-cand recall in Neptune0.128
Neo4j-only (missing from Neptune)87%
Neptune-only87%

Same #users (~1,740) and same #candidates (~2,400) but ~87% different products. NOT a query bug — §1’s two reads are logically equivalent and the 310 exact matches prove the diff works. The narrow 24h due window (predicted = last_event + avg_interval_days ∈ [now+24h, now+48h]) amplifies any per-edge last_event/avg difference into large candidate churn.

5.3 Root cause — eReceipts (PLT-926), confirmed by PH-API is_digital

Section titled “5.3 Root cause — eReceipts (PLT-926), confirmed by PH-API is_digital”

Per-edge cross-store compare (5 users) + PH-API is_digital test:

  • Neptune missing ~31% of edges + ~47% of shared edges differ in times/last/avg.
  • Decisive controls (PH is_digital): two zero-digital users → Neo4j ≈ Neptune sum(times) (gap ±5–8%, one Neptune-higher) ⇒ physical receipts MATCH. The 100%-digital user 6000548f → Neo4j 1,037 edges vs Neptune 3 ⇒ entire deficit is eReceipts. Mixed users’ deficit scales with digital count.
  • → The Neptune deficit IS the eReceipts excluded by [PLT-926] (TransformToGraphData skips receipts.IsDigital[i], matching the live DigitalReceiptData filter). Not a backfill bug, not missing physical, not precision.

5.4 eReceipt timestamps are unusable — validates the PLT-926 drop

Section titled “5.4 eReceipt timestamps are unusable — validates the PLT-926 drop”

For 6000548f (PH source):

  • purchase_fto (intended order time) = 4247183979year 2104, garbage (PLT-926: “purchase_fto unusable”).
  • scan_ts is a bulk-ingest timestamp, not an order date: 528 digital receipts → only 224 distinct exact seconds, with up to 10 receipts sharing the identical second — batch ingestion, not 10 shopping events.
  • → real order dates unrecoverable ⇒ avg_interval_days from eReceipts is noise ⇒ repurchase cadence on them is meaningless. Dropping eReceipts (live + PLT-926 backfill) is correct.

5.5 Neptune is CLEAN, Neo4j is CONTAMINATED (the key reframe)

Section titled “5.5 Neptune is CLEAN, Neo4j is CONTAMINATED (the key reframe)”

Timeline (user-confirmed + Loki):

  • PLT-926 deployed prod 2026-06-26, and the Neptune base-graph backfill was re-run 6/26 with PLT-926 active → Neptune is eReceipt-free (the correct go-forward baseline).
  • Neo4j still carries LEGACY eReceipts from per-user enrollment backfills that ran before 6/26. Loki shows 6000548f was enrolled+backfilled ~Jun 11 (request_id=backfill-…, PH lookback_days=365, 878 products / 1,840 purchases) → its eReceipt edges freeze at Jun 12; 6001aecb was backfilled ~Mar 30. Live writes never add eReceipts (ereceipt-edge last7d=0 for both users; only BTS/physical arrive live).
  • So the 87% divergence = Neptune RIGHT (physical-only) vs Neo4j WRONG (contaminated with bad-timestamp eReceipt nudges). “Parity with current Neo4j” is the wrong bar — Neo4j is the dirty side. Cutting repurchase reads to Neptune is a correctness win (it stops the bad-date eReceipt nudges; once reads flip, Neo4j’s contamination is bypassed).
  • Validate parity on PHYSICAL-only candidates with a tolerance band (not vs contaminated Neo4j). Zero-digital controls already show ~92–95% physical agreement; the residual ~5–8% is fresh-refetch timing + BTS shop-orders, amplified by the narrow 24h window — so a small tolerance is expected, not a defect.
  • Product call (leaning ACCEPT): repurchase-on-Neptune excludes eReceipt-driven nudges (coverage drop, but those nudges fired on unreliable dates). Alt: restore eReceipts only after the upstream order-date fix PLT-926 references.
  • Forecast reads the same PURCHASED edges → same eReceipt story; its wider window (~21 days vs 24h) is less timestamp-sensitive → expect higher physical recall. Forecast parity is a post-#3 step on the same physical-only basis (no new data investigation needed). Timing already validated (§5.1).
  • The aggregate “+5.4% PURCHASED on Neptune” count-parity was MISLEADING — it masks large per-user bidirectional divergence (ereceipt-heavy users → Neptune much lower; fresher users → Neptune higher).
  • Repurchase: repurchase_read_target (default neo4j), BatchSize=5000, run_hour=3, lead_hours, min_history_days=90, skip_recent_threshold=0.8.
  • Forecast: weekly_forecast.{shelf_size:10, min_shelf_items:3, lookback_days:180, min_history_days:21, window_days:7, overdue_grace_days:14, user_batch_size:500, cold_start_max_purchases:4, cold_start_min_history_days:21, cold_start_enabled:true}. No forecast_read_target yet.
  • Precompute: workers default 64 (tune low for shared writer), pageSize=5000; shards prefixLen=2 (256), concurrency=8, maxDepth=5; topK=50; max_failure_fraction=0.10.

Read-cutover PR topology & the flip asymmetry

Section titled “Read-cutover PR topology & the flip asymmetry”

The Neo4j→Neptune read cutover ships as per-read-path flags (repurchase_read_target, weekly_forecast.read_target, coach.read_target), each a static config read at boot (env-overridable for forecast/coach; repurchase is YAML-only). The three feature PRs deliberately differ in how the flip happens, and that asymmetry is intentional:

Path / PRShips in the feature PR asMerging the feature PRHow it goes live
Repurchase (#292)repurchase_read_target: neo4j (DARK)no-op (reader present, unreached)separate flip PR #295 (→ neptune, stacked on #292). Boot gate: due_now.enabled=true (satisfied). Op gate: physical-only PURCHASED parity soak (§5.6).
Weekly forecast (#293)weekly_forecast.read_target: neo4j (DARK)no-opseparate flip PR #296 (→ neptune, stacked on #293), or WEEKLY_FORECAST_READ_TARGET env. Op gate: physical-only shelf parity soak.
Coach (#294)coach.read_target: neptune (flip bundled)= the cutover for Coach-enabled usersmerging #294 IS the flip — no separate PR.

Why #294 bundles its flip while #292/#293 stay dark: Coach has a read whose Neptune behavior is not a drop-in equivalent — the A2 BrandSwap substitute discovery. The graph A2 query under-returns badly on Neptune (Jaccard 0.00–0.80 vs Neo4j; Neptune matches the full p.category string, Neo4j matches IN_CATEGORY leaf-node membership). The fix is to move A2 off the graph entirely onto the Valkey popular_substitutes snapshot (a2_source=valkey, graph-independent). Splitting Coach into “merge reader dark, then flip” would open a window where read_target=neptune is live but A2 still hits the divergent graph path. So #294 bundles read flip + timezone-from-profile-service + a2_source=valkey into one atomic, A2-safe cutover. Repurchase and forecast have no such per-read trap — their Neptune reads are equivalent to Neo4j (modulo the eReceipt data story in §5), so they can ship dark and flip later via a trivial, instantly-revertible config PR.

Operational consequence: merging #292/#293 is safe any time (dark); the cutover is the deliberate act of merging #295/#296 after the parity soak. Merging #294 is itself the cutover and routes Coach-enabled users to Neptune immediately — merge it deliberately, not as a routine dark merge. All flips are revertible (dual-write keeps both stores populated).