Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions .agent-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,48 @@

## Current System State

**v0.3.0 in progress — Milestone 5 complete (PR open).** Population generation fully
implemented: accounts, contacts, leads with all observable fields, full latent state
(8 traits across 3 entity types), motif-family-aware bias, and FK integrity guaranteed.
358 tests passing.
**v0.3.0 in progress — Milestone 6 complete (PR open).** Full mechanism library implemented:
base ABC + context, static draws, influence transforms, latent scoring, conversion hazard,
stage transitions, count intensities, categorical influence, measurement proxies, and
motif-family-aware mechanism assignment. 437 tests passing.

---

## Active Task Breakdown — Milestone 6: Mechanism Layer v1 (v0.3.0)
## Active Task Breakdown — Milestone 7: Simulation Engine (v0.3.0)

Goal: Implement the static and dynamic mechanisms that drive simulation behavior.
Goal: Run the first real evolving world and derive conversion outcomes from events.

- [ ] **1. Base mechanism interface** (`mechanisms/base.py`)
- [ ] **2. Static mechanisms** — categorical, ordinal, bounded-numeric draws (`mechanisms/static.py`)
- [ ] **3. Transition mechanisms** — lead-stage advancement logic (`mechanisms/transitions.py`)
- [ ] **4. Score/hazard mechanisms** — latent-to-observable scoring, conversion hazard (`mechanisms/scores.py`, `mechanisms/hazards.py`)
- [ ] **5. Measurement mechanisms** — noisy proxy observation of latent traits (`mechanisms/measurement.py`)
- [ ] **1. World state** (`simulation/state.py`) — per-lead mutable state during simulation
- [ ] **2. Simulation engine** (`simulation/engine.py`) — daily step loop, 90-day horizon
- [ ] **3. Event generation** — touches/sessions derived from count mechanisms
- [ ] **4. Stage advancement** — HazardTransition drives funnel progression
- [ ] **5. Conversion derivation** — ConversionHazard fires event; sets `converted_within_90_days`

---

## Context Pointers

- Milestone 6 scope: `docs/leadforge_implementation_plan.md` §9 "Milestone 6"
- Mechanism types: `docs/leadforge_architecture_spec.md` §10 "Mechanism layer"
- Latent variables: `docs/leadforge_architecture_spec.md` §9
- Milestone 7 scope: `docs/leadforge_implementation_plan.md` §10 "Milestone 7"
- Simulation spec: `docs/leadforge_architecture_spec.md` §11 "Simulation engine"
- Mechanism layer: `leadforge/mechanisms/` (all M6 files)

---

## Completed Phases

### Milestone 6 — Mechanism Layer v1 ✓ (v0.3.0 in PR)
- `mechanisms/base.py`: `Mechanism` ABC, `MechanismContext`, `MechanismSummary`, `MechanismAssignment`
- `mechanisms/static.py`: `CategoricalDraw`, `BoundedNumericDraw`, `MixtureDraw`
- `mechanisms/influence.py`: `AdditiveInfluence`, `LogisticInfluence`, `SaturatingInfluence`, `ThresholdInfluence`, `InteractionTerm`
- `mechanisms/scores.py`: `LatentScore` — logistic score from weighted latent combination
- `mechanisms/hazards.py`: `ConversionHazard` — daily conversion probability from latent score
- `mechanisms/transitions.py`: `StageSequence`, `HazardTransition` — funnel stage advancement
- `mechanisms/counts.py`: `PoissonIntensity`, `RecencyDecayIntensity` — touch/session counts
- `mechanisms/categorical.py`: `CategoricalInfluence`, `CHANNEL_QUALITY_SCORES`
- `mechanisms/measurement.py`: `NoisyProxy`, `NoisyCategorization`, `ProxyCompression`
- `mechanisms/policies.py`: `assign_mechanisms()` — motif-family-aware `MechanismAssignment` factory
- 74 tests; total 437 passing

### Milestone 5 — Population Generation ✓ (v0.3.0 in PR)
- `leadforge/simulation/population.py`: `build_population()` — accounts (3 latent traits),
contacts (4 latent traits, conditional on account), leads (1 latent trait, FK-consistent),
Expand Down
155 changes: 155 additions & 0 deletions leadforge/mechanisms/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Mechanism base classes and shared contracts.

All mechanism implementations inherit from :class:`Mechanism` and expose a
single ``sample(context, rng)`` method. :class:`MechanismContext` is the
universal carrier of state passed into every ``sample`` call.
:class:`MechanismAssignment` holds the named mechanism instances that the
simulation engine will invoke on each time step.
"""

from __future__ import annotations

import json
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any

# ---------------------------------------------------------------------------
# Context
# ---------------------------------------------------------------------------


@dataclass
class MechanismContext:
"""State snapshot passed to every :meth:`Mechanism.sample` call.

Attributes:
latents: Merged latent traits for the relevant entity set
(account + contact + lead for a full lead context).
stage: Current funnel stage of the lead, or ``None`` if not
applicable (e.g. account-level mechanisms).
t: Day index within the simulation window (0-based).
extra: Mechanism-specific extra fields (e.g. ``"channel"``,
``"rep_id"``).
"""

latents: dict[str, float] = field(default_factory=dict)
stage: str | None = None
t: int = 0
extra: dict[str, Any] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Abstract base
# ---------------------------------------------------------------------------


class Mechanism(ABC):
"""Abstract base class for all leadforge mechanism types.

Subclasses must implement :meth:`sample` and :meth:`to_dict`.
"""

@property
@abstractmethod
def name(self) -> str:
"""Short machine-readable identifier for this mechanism type."""

@abstractmethod
def sample(self, context: MechanismContext, rng: random.Random) -> Any:
"""Draw one sample given *context* using *rng*.

Args:
context: Current state snapshot.
rng: Seeded stdlib :class:`random.Random` instance.

Returns:
A value whose type depends on the mechanism family
(float, int, str, bool, or ``None``).
"""

@abstractmethod
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serialisable representation of this mechanism."""

def to_json(self) -> str:
"""Return a JSON string representation."""
return json.dumps(self.to_dict(), indent=2)


# ---------------------------------------------------------------------------
# Mechanism summary
# ---------------------------------------------------------------------------


@dataclass
class MechanismSummary:
"""Serialisable summary of the mechanism assignment for one world.

Stored in ``mechanism_summary.json`` in ``research_instructor`` mode.

Attributes:
motif_family: Name of the motif family that drove parameter choices.
conversion_hazard: Summary dict for the conversion hazard mechanism.
stage_transition: Summary dict for the stage-transition mechanism.
touch_intensity: Summary dict for the touch-count mechanism.
measurement: Summary dict for the measurement / proxy mechanism.
"""

motif_family: str
conversion_hazard: dict[str, Any]
stage_transition: dict[str, Any]
touch_intensity: dict[str, Any]
measurement: dict[str, Any]

def to_dict(self) -> dict[str, Any]:
return {
"motif_family": self.motif_family,
"conversion_hazard": self.conversion_hazard,
"stage_transition": self.stage_transition,
"touch_intensity": self.touch_intensity,
"measurement": self.measurement,
}

def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> MechanismSummary:
return cls(
motif_family=data["motif_family"],
conversion_hazard=data["conversion_hazard"],
stage_transition=data["stage_transition"],
touch_intensity=data["touch_intensity"],
measurement=data["measurement"],
)


# ---------------------------------------------------------------------------
# Mechanism assignment
# ---------------------------------------------------------------------------


@dataclass
class MechanismAssignment:
"""Named mechanism instances consumed by the simulation engine.

All fields are populated by :func:`~leadforge.mechanisms.policies.assign_mechanisms`.
"""

motif_family: str
conversion_hazard: Mechanism
stage_transition: Mechanism
touch_intensity: Mechanism
measurement: Mechanism

def summary(self) -> MechanismSummary:
"""Return a :class:`MechanismSummary` for serialisation."""
return MechanismSummary(
motif_family=self.motif_family,
conversion_hazard=self.conversion_hazard.to_dict(),
stage_transition=self.stage_transition.to_dict(),
touch_intensity=self.touch_intensity.to_dict(),
measurement=self.measurement.to_dict(),
)
68 changes: 68 additions & 0 deletions leadforge/mechanisms/categorical.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Categorical influence mechanisms — channel and segment effects.

These mechanisms map categorical context values (e.g. lead source channel,
industry segment) to a numeric influence score, allowing categorical features
to modulate latent dynamics without embedding them as continuous latents.
"""

from __future__ import annotations

import random
from typing import Any

from leadforge.mechanisms.base import Mechanism, MechanismContext


class CategoricalInfluence(Mechanism):
"""Map a categorical context key to a numeric score via a lookup table.

Looks up ``context.extra[context_key]`` in *lookup* and returns the
corresponding float. Falls back to *default* if the value is absent or
unknown.

Args:
context_key: The key to look up in ``context.extra``.
lookup: Mapping of category label → score in [0, 1].
default: Score to return when the lookup key or value is absent.
"""

def __init__(
self,
context_key: str,
lookup: dict[str, float],
default: float = 0.5,
) -> None:
if not lookup:
raise ValueError("lookup must not be empty")
self._context_key = context_key
self._lookup = dict(lookup)
self._default = default

@property
def name(self) -> str:
return "categorical_influence"

def sample(self, context: MechanismContext, rng: random.Random) -> float:
value = context.extra.get(self._context_key)
return self._lookup.get(str(value), self._default) if value is not None else self._default

def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"context_key": self._context_key,
"lookup": self._lookup,
"default": self._default,
}


# ---------------------------------------------------------------------------
# V1 channel influence scores (used by policies.py)
# ---------------------------------------------------------------------------

#: Default quality multipliers by lead source channel.
#: Partner referrals tend to arrive pre-qualified; outbound is colder.
CHANNEL_QUALITY_SCORES: dict[str, float] = {
"inbound_marketing": 0.55,
"sdr_outbound": 0.40,
"partner_referral": 0.70,
}
Loading
Loading