Skip to content

Commit df4fad8

Browse files
shaypal5claude
andauthored
feat(lifecycle): early-pLTV (tenure-anchored) snapshot [LTV-Pm] (#120)
* feat(lifecycle): early-pLTV (tenure-anchored) snapshot [LTV-Pm] Add the second observation regime (design.md §3.1 / D8): a tenure-anchored snapshot that observes every customer at a fixed short tenure (customer_start + early_tenure_weeks) — the genuine cold-start case for acquisition-time value prediction (Voyantis framing). - build_early_pltv_snapshot(population, sim, *, early_tenure_weeks=4, …) in schemes/lifecycle/snapshots.py. - Unify both regimes on one per-customer-cutoff core. The calendar and early builders now feed a shared _assemble_snapshot() driven by a customer_id -> cutoff map; the three aggregation helpers take that map instead of a single date. Feature derivations, the mrr_change_full_period trap, target attribution, and difficulty distortions are defined exactly once. The calendar regime's output is unchanged — all LTV-Pl tests pass as-is, and the lead-scoring distorted-snapshot hash is still byte-identical (196bc45f…). Semantics: - Eligibility = survival to the anchor: drops onboarding churners (churned at or before start+anchor), keeps late starters and customers who churn after the anchor. The cohort therefore differs from the calendar regime's. - Forward windows are fully simulated relative to each customer's OWN start (engine D6 runs through max(obs, start+et)+fwd), so the anchor may legitimately fall after observation_date — the builder does not require cutoff <= obs (unlike the calendar regime). - Coverage guards: early_tenure_weeks must be >= 1 and <= the sim's recorded early_tenure_weeks (else per-customer forward windows would be censored), on top of the shared forward-window / population-mismatch / observation-date checks. Known property: tenure_weeks is constant (= early_tenure_weeks) across the early table — the defining property of the regime, not a feature. The published-bundle no-zero-variance check must exempt it for this task family (noted for the validation harness, LTV-Pp). Tests (19): tenure constant at anchor; eligibility = survival to anchor; onboarding churners excluded; cohort difference vs calendar (post-anchor, pre-obs churners); per-customer censoring leakage probe (delete each customer's post-anchor events, features unchanged); targets recomputed off the per-customer cutoff vs the invoice table; cold-start sparsity (NPS all-null at 4w; health aggregates over pre-anchor signals only); anchor + horizon + mismatch + missing-obs validation; distortions leave targets and trap intact. Scope note: the actual early-pLTV *task directory* + split export (render/tasks.py) folds into LTV-Pn with the bundle/task writer, matching how LTV-Pl deferred the calendar task-split writer. This PR delivers the snapshot builder + recomputed targets. Full suite 1790 passed / 51 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lifecycle): disclose all degenerate early-pLTV columns [LTV-Pm] Findings from hostile self-review of the early-pLTV snapshot PR. 1. CALENDAR BYTE-IDENTITY: PROVEN, not just claimed. The "calendar output unchanged by the unification refactor" claim rested on derivation tests, which a subtle reordering could pass. Verified the refactored builder produces byte-identical calendar snapshots to main across all 5 motifs x 2 seeds, with and without difficulty distortions. No code change; the 30 LTV-Pl derivation tests remain the permanent guard. 2. INCOMPLETE DISCLOSURE OF DEGENERATE COLUMNS (the real finding). The PR documented only tenure_weeks as constant in the early regime, but at a short anchor MULTIPLE feature columns are dead by construction — confirmed structural (every seed), not seed accidents: - renewal_count: constant 0 for any anchor < 52w (first anniversary wk 52) - last_nps_score: all-null for any anchor < 13w (first survey wk 13) - weeks_since_last_payment_failure: near-degenerate (<=1 distinct value) Shipping a builder while under-documenting that ~3 columns are dead in its primary (4-week) configuration would mislead consumers and the validation harness. Expanded the build_early_pltv_snapshot docstring and the roadmap note to enumerate all of them with the cadence reason, flag the shared-catalog design tension, and hand LTV-Pp the full exemption list / LTV-Pn the drop-or-keep decision. New parametrized test pins the structural set across seeds so reviving any column forces a conscious update. 3. Added an early-regime trap-divergence test: the mrr_change_full_period trap is *more* leaky here than in the calendar regime (at 4 weeks mrr_change_at_snapshot is ~0 for >80% of rows while the trap captures the whole future expansion path) — pinned so the pedagogically central column can't silently stop diverging. Full suite 1794 passed / 51 skipped; ruff + mypy clean; lead-scoring distorted-snapshot hash still byte-identical (196bc45f…). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c0abf4a commit df4fad8

4 files changed

Lines changed: 556 additions & 69 deletions

File tree

.agent-plan.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,14 @@ merged (#118) — **LTV-M4 complete**. **LTV-M5**: `LTV-Pl`
6464
`CUSTOMER_SNAPSHOT_FEATURES` with the three `ltv_revenue_{90,365,730}d`
6565
targets, `churned_within_180d`, and the `mrr_change_full_period` trap;
6666
difficulty distortions extracted to scheme-agnostic `render/distortions.py`,
67-
lead-scoring byte-identical; 39 tests) opened as **#119**. Next: `LTV-Pm`
68-
(early-pLTV tenure-anchored task family).
67+
lead-scoring byte-identical) merged (#119). `LTV-Pm` (early-pLTV
68+
tenure-anchored snapshot — `build_early_pltv_snapshot()` with a per-customer
69+
relative cutoff at `customer_start + early_tenure_weeks`; calendar + early
70+
builders unified on one per-customer-cutoff core; 19 tests) opened as
71+
**#120****LTV-M5 complete** (both observation regimes). Next: `LTV-M6`
72+
(`LTV-Pn` — register LifecycleScheme + recipe + manifest/schema-v6, fold in
73+
the deferred task-split writer for both regimes + the carried layering
74+
cleanups).
6975

7076
---
7177

docs/ltv/roadmap.md

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ protocol + registry, with the package physically reorganized into
4545
| `LTV-M2` | Generation-scheme architecture + physical reorg | `LTV-Pd`, `LTV-Pe`, `LTV-Pf`, `LTV-Pg` | #107 (Pd), #108 (Pe), #109 (Pf.1), #110 (Pf.2), #111 (Pg.1), #112 (Pg.2) |
4646
| `LTV-M3` | Customer population + lifecycle world | `LTV-Ph`, `LTV-Pi` | #113 (Ph) |
4747
| `LTV-M4` | Lifecycle simulation engine | `LTV-Pj`, `LTV-Pk` | #117 (Pj), #118 (Pk) |
48-
| `LTV-M5` | Customer snapshots + pLTV targets (both regimes) | `LTV-Pl`, `LTV-Pm` | #119 (Pl) |
48+
| `LTV-M5` | Customer snapshots + pLTV targets (both regimes) | `LTV-Pl`, `LTV-Pm` | #119 (Pl), #120 (Pm) |
4949
| `LTV-M6` | Register LifecycleScheme + recipe + manifest/version | `LTV-Pn`, `LTV-Po` | |
5050
| `LTV-M7` | Validation + regression-metric calibration | `LTV-Pp` | |
5151
| `LTV-M8` | CLI, notebooks, publish | `LTV-Pq`, `LTV-Pr`, `LTV-Ps` | |
@@ -232,12 +232,36 @@ Total: ~19 PRs across 9 milestones.
232232
and can pick the cleaner semantics when its parquet schemas are fixed
233233
(Copilot review suggestion on #119).
234234
- Labels: `type: feature`, `layer: render`
235-
- [ ] **`LTV-Pm`**`feat(lifecycle): early-pLTV (tenure-anchored) task family`.
236-
Reuse the snapshot builder with a per-customer relative cutoff
237-
(`customer_start + early_tenure_weeks`) to emit the cold-start snapshot +
238-
recomputed targets (D8); separate task directory.
239-
- Tests: per-customer cutoff correctness, short-tenure sparsity, target parity,
240-
no post-cutoff leakage.
235+
- [x] **`LTV-Pm`**`feat(lifecycle): early-pLTV (tenure-anchored) snapshot`
236+
(**PR #120**). `build_early_pltv_snapshot(early_tenure_weeks=…)` in
237+
`schemes/lifecycle/snapshots.py`: per-customer relative cutoff at
238+
`customer_start + early_tenure_weeks` (D8). The calendar and early builders
239+
now share one per-customer-cutoff core (`_assemble_snapshot` + cutoff-map
240+
aggregation helpers), so feature derivations, the trap, target attribution,
241+
and distortions are defined once; the calendar regime's output is unchanged
242+
(LTV-Pl tests pass as-is). Eligibility = survival to the anchor (drops
243+
onboarding churners, keeps late starters / post-anchor churners); forward
244+
windows are fully simulated relative to each customer's own start, so the
245+
anchor may legitimately land after `observation_date`.
246+
- Tests (19): tenure constant at the anchor; eligibility = survival to
247+
anchor; cohort difference vs calendar (post-anchor pre-obs churners);
248+
per-customer censoring leakage probe; targets recomputed per-customer
249+
cutoff vs the invoice table; cold-start sparsity (NPS all-null at 4w);
250+
anchor-validation (`>= 1`, `<= sim.early_tenure_weeks`), short-window /
251+
mismatch / missing-obs guards; distortions leave targets + trap intact.
252+
- **Known degenerate columns at a short anchor (deferred to `LTV-Pp`
253+
validation):** by cadence math, several catalog columns are structurally
254+
dead in the early table — `tenure_weeks` (constant = anchor),
255+
`renewal_count` (0 for anchor < 52w), `last_nps_score` (all-null for
256+
anchor < 13w), and near-degenerate `weeks_since_last_payment_failure`.
257+
The catalog is shared with the calendar regime by design, so the
258+
no-zero-variance / no-all-null checks must exempt these for the early task
259+
family; whether to drop them from the early feature set instead is open for
260+
`LTV-Pn`.
261+
- **Deferred to `LTV-Pn` (bundle/task writer):** the actual early-pLTV
262+
*task directory* + train/valid/test split export (`render/tasks.py`,
263+
design.md §536) — this PR delivers the snapshot + recomputed targets only,
264+
matching how `LTV-Pl` deferred the calendar task-split writer.
241265
- Labels: `type: feature`, `layer: render`
242266

243267
---

0 commit comments

Comments
 (0)