Skip to content

feat(lifecycle): weekly simulation engine [LTV-Pk]#118

Merged
shaypal5 merged 3 commits into
mainfrom
feat/lifecycle-engine
Jun 12, 2026
Merged

feat(lifecycle): weekly simulation engine [LTV-Pk]#118
shaypal5 merged 3 commits into
mainfrom
feat/lifecycle-engine

Conversation

@shaypal5

Copy link
Copy Markdown
Contributor

Summary

Second half of LTV-M4 (LTV-Pk) — the weekly lifecycle simulator. simulate_lifecycle() evolves each customer week by week from their staggered start, drawing against the #117 hazard functions, and emits the three event tables (subscription_events, health_signals, invoices) plus one terminal-state subscription row per customer. This completes LTV-M4 and is the last piece before snapshots/targets (LTV-M5).

Engine contract

  • Fully simulated target windows (D6) — every customer runs through max(observation_date, start + early_tenure_weeks) + forward_window_days (default 730d), so all 90/365/730d pLTV targets are complete for both observation regimes.
  • Per-customer RNG substreams (lifecycle_sim::<customer_id>) — one customer's trajectory is invariant to every other customer. Stronger than the lead-scoring shared-stream design, and provable: a test re-simulates a single customer solo and asserts the identical event sequence.
  • Fixed weekly step order: health → invoice/dunning → churn → renewal → expansion. Causal churn reasons: payment_failure (dunning write-off), non_renewal (spiked draw on anniversary), voluntary.
  • Health → expansion causality: the week's feature_depth_score feeds the same week's expansion hazard (latents → health signals → expansion).

Engine calibration — discharging the #117 review obligation

The #117 review documented that the tenure shape invalidated the parameter annotations and deferred calibration here. Done: measured simulated first-year churn on motif-biased populations (n=600, 3 seed pairs) and retuned over three rounds:

motif before after target
product_led_retention 22.7% 18–21% ~20%
relationship_led_retention 32.3% 23–27% ~27%
expansion_led_growth 16.3% 13–18% ~15%
payment_fragile 80.5% 35–39% ~33% (write-off-driven)
churner_dominated 66.8% 41–44% ~42%

All Pi directional-test constraints held through the retune (fragile failure >2× others, fragile recovery strictly lowest, churn orderings). mechanisms.py's calibration comment rewritten from "expected to be tuned DOWN" to ENGINE-CALIBRATED with measured targets + guard-test pointer.

Also

  • CustomerPopulationResult now records motif_family — the engine fetches the same family's params; passing it separately would invite silent drift.
  • subscription_id is derived from the customer index up front and threaded into every event (an earlier draft back-filled it in an O(customers×events) pass; caught and removed before commit).
  • Tracked exclusions in the engine docstring: downgrade events (no mechanism params yet — downgrade_count would be zero-variance; must revisit before LTV-M5 ships the feature) and difficulty-tier scaling (LTV-M6).

Tests (25)

Shape/validation; byte-equal determinism across all four tables; per-customer independence (solo re-simulation); weekly health cadence; monthly invoice cadence (±1); quarterly-only NPS; full-window coverage; event FK integrity; churn-state consistency + no-events-after-churn; expansion MRR chain reconciliation; renewal-events-only-on-anniversaries + renewal_count reconciliation; dunning write-off → payment_failure churn; per-motif year-1 churn bands; expansion-world dominance; majority-active-at-observation.

  • Full suite 1712 passed / 51 skipped (+26); ruff + mypy clean.

Next

LTV-M5 / LTV-Pl — calendar-anchored customer snapshot: aggregate health/events/invoices at the absolute observation_date, compute mrr_change_at_snapshot + the mrr_change_full_period trap, and derive the three ltv_revenue_{90,365,730}d regression targets.

🤖 Generated with Claude Code

Second half of LTV-M4 — the weekly lifecycle simulator that turns a customer
population into the three event tables plus terminal subscription state.

leadforge/schemes/lifecycle/engine.py:
- simulate_lifecycle(population, seed, *, forward_window_days=730,
  early_tenure_weeks=4) → LifecycleSimulationResult{subscriptions,
  subscription_events, health_signals, invoices}.
- Fully simulated target windows (D6): each customer runs through
  max(observation_date, start + early_tenure_weeks) + forward_window_days, so
  all 90/365/730d pLTV targets are complete for BOTH observation regimes.
- Per-customer RNG substreams (lifecycle_sim::<customer_id>): one customer's
  trajectory is invariant to every other customer — stronger stability than
  the lead-scoring shared streams, and provable in a test (solo-resimulation
  equals the full-population trajectory).
- Fixed weekly step order: health signal → invoice/dunning → churn draw →
  renewal event → expansion draw. Causal churn reasons: payment_failure
  (write-off), non_renewal (spiked draw on anniversary week), voluntary.
- Health signals: weekly active_users (plan-seat base x adoption x onboarding
  usage ramp), feature_depth_score (latent plateau x ramp — feeds the same
  week's expansion hazard, creating latents → health → expansion causality),
  Knuth-Poisson support tickets (fit-driven), quarterly NPS (null off-cycle).
- Invoices on month boundaries (12/52 weeks) at current MRR; failures enter
  dunning and resolve to recovered or written_off → forced churn.
- subscription_id derived from the customer index up front and threaded into
  every event (no back-fill pass).

leadforge/schemes/lifecycle/population.py:
- CustomerPopulationResult records motif_family so the engine fetches the same
  family's mechanism params (passing it separately would invite silent drift).

leadforge/schemes/lifecycle/mechanisms.py — ENGINE CALIBRATION (discharges the
obligation recorded in the #117 review):
- Measured simulated first-year churn on motif-biased populations (n=600,
  3 seed pairs) and retuned churn base rates, payment-failure rates, and
  recovery rates over three rounds. Before: payment_fragile 80.5%,
  churner_dominated 66.8%. After: product_led ~19-21%, relationship ~23-27%,
  expansion_led ~13-18%, payment_fragile ~35-39%, churner_dominated ~41-44% —
  matching the per-motif intent while honouring the Pi directional-test
  constraints (fragile failure > 2x others, fragile recovery strictly lowest).
- Calibration comment rewritten from "expected to be tuned DOWN" to
  ENGINE-CALIBRATED with the measured targets and a pointer to the guard test.

tests/schemes/lifecycle/test_engine.py (25 tests): shape/validation,
byte-equal determinism across all four tables, per-customer independence
(solo-resimulation), weekly health cadence, monthly invoice cadence (±1),
quarterly-only NPS, full-window coverage for active customers, event FK
integrity, churn-state consistency + no-events-after-churn, expansion MRR
chain reconciliation (events chain to subscription.current_mrr and
expansion_count), renewal-events-only-on-anniversaries + renewal_count
reconciliation, dunning resolution / write-off → payment_failure churn,
per-motif year-1 churn bands, expansion-world dominance, majority-active-at-
observation sanity.

Engine docstring records the two tracked exclusions: downgrade events (no
mechanism params yet — downgrade_count would be zero-variance; revisit before
LTV-M5 ships the feature) and difficulty-tier scaling (LTV-M6).

Full suite 1712 passed / 51 skipped; ruff + mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 12, 2026 09:35
@shaypal5 shaypal5 added this to the dataset: leadforge-ltv-v1 milestone Jun 12, 2026
@shaypal5 shaypal5 added type: feature New capability layer: simulation simulation/ discrete-time engine status: needs review Ready for review dataset: leadforge-ltv-v1 Issue/PR scoped to the b2b_saas_ltv_v1 LTV dataset workstream labels Jun 12, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the weekly lifecycle simulation engine (simulate_lifecycle) that evolves each customer from their staggered start date through the required forward windows, drawing against the lifecycle hazard functions and emitting the lifecycle event tables plus a terminal subscription row per customer. This completes the “engine” portion of the LTV lifecycle milestone and adds a dedicated test suite to lock down determinism, cadences, and calibration bands.

Changes:

  • Added leadforge.schemes.lifecycle.engine.simulate_lifecycle() with per-customer RNG substreams, weekly step ordering, and emission of subscription_events, health_signals, and invoices.
  • Calibrated lifecycle mechanism base rates and added engine-level calibration/consistency tests.
  • Recorded motif_family on generated populations so the engine uses the matching parameter family.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/schemes/lifecycle/test_engine.py Adds comprehensive engine tests (determinism, cadences, integrity checks, calibration bands).
leadforge/schemes/lifecycle/population.py Persists motif_family on CustomerPopulationResult and populates it in the builder.
leadforge/schemes/lifecycle/mechanisms.py Updates calibration commentary and retunes churn/payment parameters to match engine-calibrated targets.
leadforge/schemes/lifecycle/engine.py Introduces the weekly simulator, event emission, health/invoice generation, and churn/renewal/expansion logic.
docs/ltv/roadmap.md Updates roadmap references to include PR #118 and marks #117 complete.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

nps_score=nps,
)
)
return depth
Comment thread leadforge/schemes/lifecycle/engine.py Outdated
Comment on lines +287 to +291
month_index = int(week / _WEEKS_PER_MONTH)
if month_index > last_month_index:
last_month_index = month_index
counters["invoice"] += 1
failed = rng.random() < payment_failure_probability(payment_p, latents)
Comment thread leadforge/schemes/lifecycle/engine.py Outdated
Comment on lines +299 to +302
result.invoices.append(invoice)
if failed and state.pending_failure is None:
state.pending_failure = (invoice, week)
_emit_event(
Comment on lines +344 to +346
# -- 3. Churn draw ---------------------------------------------------
renewal_week = is_renewal_week(week, state.contract_term_months)
p_churn = churn_probability(churn_p, latents, week, state.contract_term_months)
Comment on lines +332 to +342
_emit_event(
result,
counters,
subscription_id=subscription_id,
customer_id=customer.customer_id,
week_date=week_date,
event_type="churn",
mrr_before=state.current_mrr,
mrr_after=0,
)
break
Comment on lines +349 to +359
_emit_event(
result,
counters,
subscription_id=subscription_id,
customer_id=customer.customer_id,
week_date=week_date,
event_type="churn",
mrr_before=state.current_mrr,
mrr_after=0,
)
break
Comment on lines +257 to +267
def test_payment_failures_resolve_or_censor(population, sim) -> None:
# Every failed invoice ends as recovered / written_off, unless the customer
# churned (or the window ended) before the dunning period elapsed.
churned = {s.customer_id: s for s in sim.subscriptions if s.churn_at}
for inv in sim.invoices:
assert inv.payment_status in ("paid", "failed", "recovered", "written_off")
if inv.payment_status == "written_off":
sub = churned.get(inv.customer_id)
assert sub is not None
assert sub.churn_reason == "payment_failure"

Comment on lines +225 to +240
def test_mrr_chain_consistency(population, sim) -> None:
initial = {c.customer_id: c.initial_mrr for c in population.customers}
expansions: dict[str, list] = {}
for e in sim.subscription_events:
if e.event_type == "expansion":
expansions.setdefault(e.customer_id, []).append(e)
assert e.mrr_after > e.mrr_before
for sub in sim.subscriptions:
chain = expansions.get(sub.customer_id, [])
mrr = initial[sub.customer_id]
for e in chain:
assert e.mrr_before == mrr
mrr = e.mrr_after
assert sub.current_mrr == mrr
assert sub.expansion_count == len(chain)

Three findings from hostile self-review of the initial engine commit:

1. ACCOUNT LATENTS WERE COMPLETELY DEAD (the real one). The customer latent
   dict contains every account latent key (latent_budget_stability,
   latent_organizational_stability) plus three more, and the merge let
   customer values shadow account values — so every account-level draw was
   discarded for every customer, and the comment called the collision
   "(deliberate)". This also destroyed the within-account correlation that
   account-level draws exist to provide (~3 customers share an account).
   Fix: explicit _merge_latents helper that blends shared traits 50/50 —
   the account component is a shared random effect, giving correlated churn
   and payment behaviour within an account (mixed-effects structure).
   Calibration re-verified after the change: all five motifs stay inside
   their year-1 churn bands across three seed pairs.
   Regression test: swinging account latent_budget_stability 0.0 vs 1.0
   must change the population's failed-invoice count.

2. Intra-week ordering: invoices were issued BEFORE pending dunning resolved.
   Consequences: (a) a customer's write-off churn week could include a fresh
   same-week invoice (whose paid amount would count toward pLTV revenue);
   (b) for dunning_weeks=4 motifs with a 4-week month gap, a second invoice
   could fail while one was pending and be silently dropped — terminal
   "failed" status forever, no event, no dunning. Fix: dunning resolution now
   runs before invoice issuance, so the pending slot is always free by
   issuance time. The only remaining "failed" terminal states are genuine
   censoring (churn for another reason mid-dunning, or window end) — now
   documented in the module docstring and pinned by a test that checks every
   dangling "failed" invoice against exactly those two conditions.

3. The recorded feature_depth_score was rounded to 4dp but the UNROUNDED
   value fed the expansion hazard — the published observable differed (by ε)
   from the value that drove behaviour, breaking the data↔causality
   equivalence this dataset exists to teach. The hazard now consumes the
   exact rounded value the row records; round-trip test added.

Full suite 1715 passed / 51 skipped; ruff + mypy clean; calibration bands
re-verified post-blend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

pr-agent-context report:

This run includes unresolved review comments on PR #118 in repository https://github.com/leadforge-dev/leadforge

For each unresolved review comment, recommend one of: resolve as irrelevant, accept and implement
the recommended solution, open a separate issue and resolve as out-of-scope for this PR, accept and
implement a different solution, or resolve as already treated by the code.

After I reply with my decision per item, implement the accepted actions, resolve the corresponding
PR comments, and push all of these changes in a single commit.

# Copilot Comments

## COPILOT-1
Location: leadforge/schemes/lifecycle/engine.py:517
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279614
Root author: copilot-pull-request-reviewer

Comment:
    The expansion hazard is supposed to be driven by the same week’s `feature_depth_score`, but `_emit_health_signal()` returns the unrounded `depth` while persisting `round(depth, 4)` in the table. This makes the stored health signal slightly inconsistent with the value used for expansion draws (and can break attempts to replay/inspect hazard inputs from the emitted tables).

## COPILOT-2
Location: leadforge/schemes/lifecycle/engine.py
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279660
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    If an invoice fails in the same week that a prior pending failure is recovered (dunning resolution), `state.pending_failure` is cleared later in the loop and the new failed invoice is left permanently in `payment_status == "failed"` with no dunning resolution path. Track the newly created invoice so you can re-promote it to `pending_failure` after clearing the prior one.

## COPILOT-3
Location: leadforge/schemes/lifecycle/engine.py
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279679
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    `new_invoice` needs to be set when an invoice is created so later logic can detect the “failed invoice created during recovery week” case and attach dunning to it.

## COPILOT-4
Location: leadforge/schemes/lifecycle/engine.py:374
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279705
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    After recovering a prior pending failure, if the invoice created this same week also failed, it should immediately become the new `pending_failure` (and emit a `payment_failure` event). Otherwise that invoice will remain stuck in `failed` forever with no recovery/write-off outcome.

## COPILOT-5
Location: leadforge/schemes/lifecycle/engine.py:344
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279729
Root author: copilot-pull-request-reviewer

Comment:
    When churn happens, the churn event records `mrr_after=0` but the terminal subscription row will still carry the pre-churn `current_mrr`, which makes the terminal state internally inconsistent ("churned" subscriptions still have positive MRR). Set `state.current_mrr = 0` when churn is triggered so the subscription row reflects the post-churn terminal state.

## COPILOT-6
Location: leadforge/schemes/lifecycle/engine.py:387
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279759
Root author: copilot-pull-request-reviewer

Comment:
    Same as the payment-failure churn path: after emitting a churn event with `mrr_after=0`, the simulated terminal subscription should have `current_mrr == 0` so the output tables are consistent.

## COPILOT-7
Location: tests/schemes/lifecycle/test_engine.py:267
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279786
Root author: copilot-pull-request-reviewer

Comment:
    This test is named/commented as if it asserts that failed invoices "resolve or censor", but it currently only checks that `payment_status` is in an allowed set and that `written_off` implies payment-failure churn. As written, it will not catch failures that remain stuck as `"failed"` long past the dunning window (including the engine edge case where a new failure occurs on a recovery week). Strengthen it to assert that any remaining `"failed"` invoice is within the dunning window of churn or the simulation horizon for that customer.

## COPILOT-8
Location: tests/schemes/lifecycle/test_engine.py:240
URL: https://github.com/leadforge-dev/leadforge/pull/118#discussion_r3402279807
Root author: copilot-pull-request-reviewer

Comment:
    `test_mrr_chain_consistency` currently asserts `sub.current_mrr == mrr` for *all* subscriptions, which will mask (or codify) the inconsistency of churned subscriptions having a non-zero terminal `current_mrr`. If churn sets terminal MRR to 0 (matching churn event `mrr_after`), this test should assert that behavior explicitly for churned rows.

Run metadata:

Tool ref: v4
Tool version: 4.0.21
Trigger: commit pushed
Workflow run: 27407827082 attempt 1
Comment timestamp: 2026-06-12T09:42:10.792747+00:00
PR head commit: 681872ffabca252c33a587054d453f3caf341d26

@shaypal5 shaypal5 merged commit f66519c into main Jun 12, 2026
10 checks passed
@shaypal5 shaypal5 deleted the feat/lifecycle-engine branch June 12, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dataset: leadforge-ltv-v1 Issue/PR scoped to the b2b_saas_ltv_v1 LTV dataset workstream layer: simulation simulation/ discrete-time engine status: needs review Ready for review type: feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants