feat(lifecycle): motif families + mechanism policies [LTV-Pi]#116
Merged
Conversation
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
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.pywith frozen dataclasses for mechanism parameters, calibrated per-motif tables, andassign_lifecycle_mechanisms()+mechanism_params_for_motif(). - Added
tests/schemes/lifecycle/test_mechanisms.pycovering 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>
|
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: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the lifecycle mechanism policy layer (
LTV-Pi, milestoneLTV-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 spikeExpansionPropensityParams— weekly upsell/seat-add probability; MRR delta fraction rangePaymentFailureParams— monthly payment-failure probability; dunning + recoveryPer-motif parameter tables for all 5 retention motif families, calibrated to be coherent with the population latent-bias design (from
LTV-Ph):product_led_retentionrelationship_led_retentionexpansion_led_growthpayment_fragilechurner_dominatedassign_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.Next
LTV-Pj— churn/expansion/payment hazard implementations (schemes/lifecycle/mechanisms_hazards.py) — the callable mechanisms driven by these parameter tables. ThenLTV-Pk— the weekly simulation engine.🤖 Generated with Claude Code