Skip to content

feat(lifecycle): motif families + mechanism policies [LTV-Pi]#116

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

feat(lifecycle): motif families + mechanism policies [LTV-Pi]#116
shaypal5 merged 3 commits into
mainfrom
feat/lifecycle-mechanism-policies

Conversation

@shaypal5

Copy link
Copy Markdown
Contributor

Summary

Adds the lifecycle mechanism policy layer (LTV-Pi, milestone LTV-M3) — the parameter tables and dispatch function the weekly simulation engine (LTV-Pj/Pk) will call to determine how each world behaves.

What's added

leadforge/schemes/lifecycle/mechanisms.py:

Three frozen dataclasses covering the three mechanism types (design.md §6):

  • ChurnHazardParams — weekly churn probability; background rate + renewal spike
  • ExpansionPropensityParams — weekly upsell/seat-add probability; MRR delta fraction range
  • PaymentFailureParams — monthly payment-failure probability; dunning + recovery

Per-motif parameter tables for all 5 retention motif families, calibrated to be coherent with the population latent-bias design (from LTV-Ph):

family churn base expansion base payment failure
product_led_retention 0.0042/wk (~20%/yr) 0.0045/wk 1.5%/mo
relationship_led_retention 0.0055/wk (~25%/yr) 0.0030/wk 2.0%/mo
expansion_led_growth 0.0028/wk (~14%/yr) (lowest) 0.0075/wk (highest) 1.2%/mo
payment_fragile 0.0060/wk (~27%/yr) 0.0020/wk 8.0%/mo (highest)
churner_dominated 0.0090/wk (~38%/yr) (highest) 0.0018/wk 3.0%/mo

assign_lifecycle_mechanisms(motif_family) → LifecycleMechanismAssignment — the single public entry point. Unknown families fall back to defaults rather than raising, allowing new families to be prototyped before calibration.

mechanism_params_for_motif() — plain-dict inspection helper.

Tests (74)

tests/schemes/lifecycle/test_mechanisms.py: dispatch, value ranges, structural ordering assertions (churner_dominated highest churn; expansion_led_growth highest expansion and lowest churn; payment_fragile highest failure and lowest recovery), latent-weight structure, frozen-dataclass immutability, and params-dict consistency.

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

Next

LTV-Pj — churn/expansion/payment hazard implementations (schemes/lifecycle/mechanisms_hazards.py) — the callable mechanisms driven by these parameter tables. Then LTV-Pk — the weekly simulation engine.

🤖 Generated with Claude Code

Adds the lifecycle mechanism policy layer — the parameter tables and dispatch
function the weekly simulation engine will call to know how each world behaves.

leadforge/schemes/lifecycle/mechanisms.py:
- Three frozen dataclasses: ChurnHazardParams, ExpansionPropensityParams,
  PaymentFailureParams — covering the three mechanism types from design.md §6.
- Per-motif parameter tables for all five retention motif families:
  product_led_retention, relationship_led_retention, expansion_led_growth,
  payment_fragile, churner_dominated.
  Each table is calibrated to be structurally coherent with the latent-bias
  design in population.py: churner_dominated has the highest base churn rate;
  expansion_led_growth has the lowest churn + highest expansion; payment_fragile
  has the highest payment-failure rate and lowest recovery rate.
- assign_lifecycle_mechanisms(motif_family) → LifecycleMechanismAssignment:
  the single public entry point that constructs all three param objects.
  Unknown motif families fall back to defaults rather than raising, so a new
  family can be prototyped before its tables are calibrated.
- mechanism_params_for_motif(): inspection helper returning a plain dict.

tests/schemes/lifecycle/test_mechanisms.py (74 tests):
- Dispatch: assignment returned for every motif; all three params present;
  unknown motif falls back gracefully.
- Value ranges: rates in (0, 1); renewal multiplier > 1; MRR frac range valid;
  dunning_weeks >= 1; recovery_rate in [0, 1].
- Structural ordering: churner_dominated has highest churn; expansion_led_growth
  has highest expansion and lowest churn; payment_fragile has highest payment
  failure and lowest recovery.
- Latent weights non-empty; reference valid lifecycle trait names only.
- Frozen dataclasses: mutation raises.
- mechanism_params_for_motif: covers all keys and is consistent with assignment.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 11, 2026 20:49
@shaypal5 shaypal5 added this to the dataset: leadforge-ltv-v1 milestone Jun 11, 2026
@shaypal5 shaypal5 added type: feature New capability layer: mechanisms mechanisms/ generators and transitions status: needs review Ready for review dataset: leadforge-ltv-v1 Issue/PR scoped to the b2b_saas_ltv_v1 LTV dataset workstream labels Jun 11, 2026
Co-Authored-By: Claude Sonnet 4.6 <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

Adds the lifecycle “mechanism policy” layer for the lifecycle scheme by introducing per-motif parameter tables and a dispatch function that returns a single assignment object consumed by the upcoming weekly simulation engine.

Changes:

  • Added leadforge/schemes/lifecycle/mechanisms.py with frozen dataclasses for mechanism parameters, calibrated per-motif tables, and assign_lifecycle_mechanisms() + mechanism_params_for_motif().
  • Added tests/schemes/lifecycle/test_mechanisms.py covering dispatch behavior, parameter ranges, motif ordering invariants, and basic immutability checks.
  • Updated lifecycle roadmap / agent plan to reflect milestone and PR status.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 12 comments.

File Description
leadforge/schemes/lifecycle/mechanisms.py Introduces mechanism parameter dataclasses, per-motif policy tables, and motif→policy dispatch/inspection helpers.
tests/schemes/lifecycle/test_mechanisms.py Adds tests for dispatch correctness, parameter sanity, motif ordering invariants, and immutability assertions.
docs/ltv/roadmap.md Marks LTV-Ph complete and links LTV-Pi to PR #116.
.agent-plan.md Updates planning/status notes for LTV-M3 and this PR’s scope.

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

Comment on lines +41 to +42
from dataclasses import dataclass
from typing import Any
Comment on lines +66 to +70
base_weekly_rate: float
latent_weights: dict[str, float]
renewal_hazard_multiplier: float
renewal_latent_weights: dict[str, float]

Comment on lines +83 to +86
base_weekly_rate: float
latent_weights: dict[str, float]
expansion_mrr_frac_range: tuple[float, float]

Comment on lines +102 to +106
base_monthly_rate: float
latent_weights: dict[str, float]
dunning_weeks: int
recovery_rate: float

Comment on lines +389 to +391
"churn_latent_weights": _CHURN_LATENT_WEIGHTS.get(
motif_family, _DEFAULT_CHURN_LATENT_WEIGHTS
),
Comment on lines +410 to +412
"payment_failure_latent_weights": _PAYMENT_FAILURE_LATENT_WEIGHTS.get(
motif_family, _DEFAULT_PAYMENT_FAILURE_LATENT_WEIGHTS
),
Comment on lines +237 to +238
# MRR delta fraction range (lo, hi) for expansion events.
# Expansion MRR = randint(lo * current_mrr, hi * current_mrr).
Comment on lines +94 to +95
latent_weights: Trait weights (negative ``latent_budget_stability``
increases failure probability).
Comment on lines +256 to +258
# Latent weights for payment failure.
# Negative latent_budget_stability increases failure probability.
_PAYMENT_FAILURE_LATENT_WEIGHTS: dict[str, dict[str, float]] = {
Comment on lines +197 to +201
def test_churn_params_are_frozen() -> None:
a = assign_lifecycle_mechanisms("product_led_retention")
with pytest.raises((AttributeError, TypeError)):
a.churn_hazard.base_weekly_rate = 0.99 # type: ignore[misc]

…TV-Pi]

Four issues found by hostile self-review of the initial commit:

1. latent_weights dicts were NOT actually immutable (BUG).
   frozen=True on dataclasses prevents attribute reassignment but not
   dict mutation. A simulation engine receiving the LifecycleMechanismAssignment
   could silently corrupt the shared module-level dicts.
   Fix: wrap every latent_weights field in MappingProxyType so mutation
   raises TypeError. Added test_latent_weights_dicts_are_truly_immutable.

2. mechanism_params_for_motif duplicated all 12 .get() lookups from
   assign_lifecycle_mechanisms. Any table change required two edits.
   Fix: rewrite to call assign_lifecycle_mechanisms() and reflect its fields,
   guaranteeing consistency by construction.

3. Annual churn rate annotations were systematically inaccurate (off by
   0.3–2.5pp). Corrected to 1-(1-r)^52 exact values with a formula note.

4. Structural ordering tests (highest/lowest churn, expansion, failure)
   pinned absolute rankings across all 5 families and would block valid
   recalibration. Replaced with directional pair comparisons (e.g.
   churner_dominated > product_led_retention) and a 2× threshold test for
   payment_fragile. These express the semantically load-bearing invariants
   without over-specifying the calibration.

Also: added __all__ to declare the public surface.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

pr-agent-context report:

This run includes unresolved review comments on PR #116 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/mechanisms.py:43
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399104923
Root author: copilot-pull-request-reviewer

Comment:
    The module defines frozen dataclasses but still uses mutable dict fields below; add Mapping/MappingProxyType imports so the mappings can be made truly immutable (consistent with other frozen specs like NodeSpec).

## COPILOT-2
Location: leadforge/schemes/lifecycle/mechanisms.py:80
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399104963
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    These fields are typed as dicts inside a frozen dataclass, which still allows callers to mutate the contents (e.g., assignment.latent_weights["x"] = ...). If the intent is immutability, store these as read-only mappings (MappingProxyType) like lead_scoring/structure/graph.py does for metadata.

## COPILOT-3
Location: leadforge/schemes/lifecycle/mechanisms.py:96
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399104987
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    This frozen dataclass still exposes a mutable dict via latent_weights, so it is not actually immutable. Consider freezing the mapping (MappingProxyType) to prevent accidental mutation of mechanism parameters after dispatch.

## COPILOT-4
Location: leadforge/schemes/lifecycle/mechanisms.py:116
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105009
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    Like the other mechanism param classes, this frozen dataclass still allows mutating latent_weights in-place. If callers treat these assignments as immutable policy objects, wrap the mapping in MappingProxyType during initialization.

## COPILOT-5
Location: leadforge/schemes/lifecycle/mechanisms.py
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105034
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    mechanism_params_for_motif() currently returns a direct reference to the module-level latent-weights dict for this motif. That allows callers to mutate the global parameter tables (e.g., params["churn_latent_weights"][...] = ...), affecting subsequent simulations/tests. Return a copy here to keep the tables immutable from the outside.

## COPILOT-6
Location: leadforge/schemes/lifecycle/mechanisms.py
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105049
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    This dict entry returns the internal renewal-latent-weights mapping directly, so external callers can mutate the module-level tables. Return a copy to avoid leaking mutable shared state.

## COPILOT-7
Location: leadforge/schemes/lifecycle/mechanisms.py
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105062
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    This returns the internal expansion-latent-weights mapping directly; callers can accidentally mutate the global parameter table. Return a copy for safety.

## COPILOT-8
Location: leadforge/schemes/lifecycle/mechanisms.py
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105074
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    This returns the internal payment-failure latent-weights mapping by reference, which allows external mutation of the module-level tables. Return a copy to avoid shared-state bugs.

## COPILOT-9
Location: leadforge/schemes/lifecycle/mechanisms.py:249
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105084
Root author: copilot-pull-request-reviewer

Comment:
    This comment says expansion MRR uses randint(), but the ExpansionPropensityParams docstring above says the delta is drawn uniformly from the range. Pick one wording to avoid conflicting documentation (and avoid naming a specific RNG API before the hazard implementation exists).

## COPILOT-10
Location: leadforge/schemes/lifecycle/mechanisms.py
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105102
Status: outdated
Root author: copilot-pull-request-reviewer

Comment:
    This docstring implies that a *negative weight* on latent_budget_stability increases failure probability, but with stability defined as higher=better, a negative weight actually means lower stability increases failure risk. Reword to match the intended direction.

## COPILOT-11
Location: leadforge/schemes/lifecycle/mechanisms.py:269
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105120
Root author: copilot-pull-request-reviewer

Comment:
    This comment is phrased as if a negative weight itself increases failure probability; but the intended meaning is that *lower* latent_budget_stability increases failure probability (implemented via a negative weight). Clarify to avoid confusion during hazard implementation.

## COPILOT-12
Location: tests/schemes/lifecycle/test_mechanisms.py:198
URL: https://github.com/leadforge-dev/leadforge/pull/116#discussion_r3399105140
Root author: copilot-pull-request-reviewer

Comment:
    The immutability tests only verify that dataclass attributes can’t be reassigned, but (with dict fields) callers can still mutate latent_weights in-place. If the mechanism params are meant to be immutable policy objects, add a regression test that mutating latent_weights raises (e.g. via MappingProxyType).

Run metadata:

Tool ref: v4
Tool version: 4.0.21
Trigger: commit pushed
Workflow run: 27376963660 attempt 1
Comment timestamp: 2026-06-11T20:57:58.755972+00:00
PR head commit: 43e0e376756759b87b7950e82a6878958fc11ae1

@shaypal5 shaypal5 merged commit dc0c0b2 into main Jun 12, 2026
10 checks passed
@shaypal5 shaypal5 deleted the feat/lifecycle-mechanism-policies branch June 12, 2026 07:22
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: mechanisms mechanisms/ generators and transitions 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