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
7 changes: 7 additions & 0 deletions .agent-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ Documentation + CI:
- [x] `leadforge/pipelines/build_v6.py` — same `label_column` kwarg on `rename_and_select()`
- [x] 16 new tests: `task_manifest_for_config`, bundle layout, manifest keys, validation with custom task, pipeline rename with custom label column; total 773 passing

### Generalize dataset card prose (PR #41, closes #38)

- [x] `leadforge/schema/tasks.py` — `TaskManifest.description` rewritten for dataset-card use; `task_manifest_for_config()` produces task-specific prose (conversion-specific for default task, generic for others)
- [x] `leadforge/narrative/dataset_card.py` — `render_dataset_card()` accepts optional `task_manifest` kwarg; uses `description` for primary task prose when provided, task-agnostic fallback otherwise
- [x] `leadforge/api/bundle.py` — threads `TaskManifest` from `write_bundle()` to `render_dataset_card()`
- [x] 5 new tests: custom description, default conversion prose, generic fallback (no manifest), generic fallback (empty description), non-default task via factory; total 781 passing

### Parquet metadata row counts (PR #37, closes #17)

- [x] `leadforge/validation/bundle_checks.py` — `_check_task_splits()` uses `pq.read_metadata().num_rows` instead of `pd.read_parquet()`; `_check_leakage()` uses `pq.read_schema().names` instead of `pd.read_parquet(columns=[])`
Expand Down
2 changes: 1 addition & 1 deletion leadforge/api/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def write_bundle(
# ------------------------------------------------------------------
# 3. Dataset card and feature dictionary
# ------------------------------------------------------------------
(root / "dataset_card.md").write_text(render_dataset_card(bundle.spec))
(root / "dataset_card.md").write_text(render_dataset_card(bundle.spec, task_manifest=task))
write_feature_dictionary(root / "feature_dictionary.csv")

# ------------------------------------------------------------------
Expand Down
24 changes: 20 additions & 4 deletions leadforge/narrative/dataset_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@

if TYPE_CHECKING:
from leadforge.core.models import WorldSpec
from leadforge.schema.tasks import TaskManifest


def render_dataset_card(world_spec: WorldSpec) -> str:
def render_dataset_card(
world_spec: WorldSpec,
task_manifest: TaskManifest | None = None,
) -> str:
"""Return a Markdown dataset card string for *world_spec*.

Comment thread
shaypal5 marked this conversation as resolved.
Args:
world_spec: The world specification containing config and narrative.
task_manifest: Optional task manifest whose ``description`` is used
as the label definition prose. When ``None`` or when
``description`` is empty, a generic fallback is rendered.

Sections present at all milestones:
- Header (recipe id, version, seed, exposure mode)
- Narrative summary (company, product, market, GTM)
Expand Down Expand Up @@ -94,14 +104,20 @@ def render_dataset_card(world_spec: WorldSpec) -> str:
# ------------------------------------------------------------------
# Primary task
# ------------------------------------------------------------------
if task_manifest is not None and task_manifest.description:
label_def = task_manifest.description
else:
label_def = (
f"Binary label evaluated over a {cfg.label_window_days}-day window "
f"from the snapshot anchor date. The label is event-derived — never "
f"sampled directly."
)
lines += [
"## Primary task",
"",
f"**Task:** `{cfg.primary_task}`",
"",
f"**Label definition:** A lead is considered converted if a `closed_won` event "
f"is recorded within {cfg.label_window_days} days of the lead's snapshot anchor date. "
"The label is derived from simulated events — it is never sampled directly.",
f"**Label definition:** {label_def}",
"",
]

Expand Down
33 changes: 22 additions & 11 deletions leadforge/schema/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ class TaskManifest:
label_column: Column name in the task Parquet files that holds the
binary label.
label_window_days: Number of days after the snapshot anchor date
within which a conversion event counts as positive.
within which the target event counts as positive.
primary_table: The relational table the snapshot rows are derived
from (usually ``"leads"``).
split: Train/valid/test proportions.
task_type: ML task type string (``"binary_classification"`` for v1).
description: Human-readable description of the task.
description: Human-readable description of the task, suitable for
display in dataset cards and documentation.
"""

task_id: str
Expand Down Expand Up @@ -99,9 +100,10 @@ def to_dict(self) -> dict[str, object]:
split=SplitSpec(train=0.7, valid=0.15, test=0.15),
task_type="binary_classification",
description=(
"Predict whether a lead converts (closed_won event) within 90 days "
"of the snapshot anchor date. Label is event-derived — never sampled "
"directly. All features are pre-anchor (leakage-free by construction)."
"A lead is considered converted if a `closed_won` event is recorded "
"within 90 days of the lead's snapshot anchor date. The label is "
"event-derived — never sampled directly. All features are pre-anchor "
"(leakage-free by construction)."
),
)

Expand All @@ -121,14 +123,23 @@ def task_manifest_for_config(
manifest key.
label_window_days: Label observation window in days.
"""
if primary_task == CONVERTED_WITHIN_90_DAYS.task_id:
description = (
f"A lead is considered converted if a `closed_won` event is recorded "
f"within {label_window_days} days of the lead's snapshot anchor date. "
f"The label is event-derived — never sampled directly. All features "
f"are pre-anchor (leakage-free by construction)."
)
else:
description = (
f"Binary label `{primary_task}` evaluated over a "
f"{label_window_days}-day window from the snapshot anchor date. "
f"The label is event-derived — never sampled directly. All features "
f"are pre-anchor (leakage-free by construction)."
)
return replace(
CONVERTED_WITHIN_90_DAYS,
task_id=primary_task,
label_window_days=label_window_days,
description=(
f"Predict whether a lead converts (closed_won event) within "
f"{label_window_days} days of the snapshot anchor date. Label is "
f"event-derived — never sampled directly. All features are "
f"pre-anchor (leakage-free by construction)."
),
description=description,
)
86 changes: 79 additions & 7 deletions tests/narrative/test_dataset_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from leadforge.api.generator import Generator
from leadforge.core.models import GenerationConfig, WorldSpec
from leadforge.narrative.dataset_card import render_dataset_card
from leadforge.schema.tasks import SplitSpec, TaskManifest, task_manifest_for_config


def _make_world_spec(**kwargs: object) -> WorldSpec:
Expand Down Expand Up @@ -41,30 +42,33 @@ def test_card_contains_primary_task() -> None:


def test_card_contains_label_definition() -> None:
card = render_dataset_card(_make_world_spec())
task = task_manifest_for_config()
card = render_dataset_card(_make_world_spec(), task_manifest=task)
assert "closed_won" in card
assert "90 days" in card


def test_card_renders_custom_primary_task() -> None:
spec = _make_world_spec(primary_task="churned_within_60_days")
card = render_dataset_card(spec)
task = task_manifest_for_config("churned_within_60_days", 60)
card = render_dataset_card(spec, task_manifest=task)
assert "`churned_within_60_days`" in card
assert "converted_within_90_days" not in card


def test_card_renders_custom_label_window_days() -> None:
spec = _make_world_spec(label_window_days=60)
card = render_dataset_card(spec)
assert "within 60 days" in card
assert "within 90 days" not in card
task = task_manifest_for_config(label_window_days=60)
card = render_dataset_card(spec, task_manifest=task)
assert "60" in card


def test_card_renders_custom_task_and_window() -> None:
spec = _make_world_spec(primary_task="upgraded_within_30_days", label_window_days=30)
card = render_dataset_card(spec)
task = task_manifest_for_config("upgraded_within_30_days", 30)
card = render_dataset_card(spec, task_manifest=task)
assert "`upgraded_within_30_days`" in card
assert "30 days" in card
assert "30" in card


def test_card_contains_use_cases() -> None:
Expand Down Expand Up @@ -131,3 +135,71 @@ def test_generator_world_spec_is_world_spec() -> None:

gen = Generator.from_recipe("b2b_saas_procurement_v1")
assert isinstance(gen.world_spec, WorldSpec)


# ---------------------------------------------------------------------------
# Task manifest threading (issue #38)
# ---------------------------------------------------------------------------


def test_card_uses_task_manifest_description() -> None:
"""When a TaskManifest is provided, its description replaces default prose."""
spec = _make_world_spec(primary_task="churned_within_60_days", label_window_days=60)
task = TaskManifest(
task_id="churned_within_60_days",
label_column="churned_within_60_days",
label_window_days=60,
primary_table="leads",
split=SplitSpec(train=0.7, valid=0.15, test=0.15),
description=(
"A lead is considered churned if a `churn` event is recorded "
"within 60 days of the snapshot anchor date."
),
)
card = render_dataset_card(spec, task_manifest=task)
assert "churned" in card
assert "churn" in card
assert "closed_won" not in card


def test_card_default_task_manifest_has_conversion_prose() -> None:
"""Default task manifest produces conversion-specific prose."""
spec = _make_world_spec()
task = task_manifest_for_config()
card = render_dataset_card(spec, task_manifest=task)
assert "closed_won" in card
assert "90 days" in card


def test_card_without_task_manifest_uses_generic_fallback() -> None:
"""Without a TaskManifest, the card uses a task-agnostic fallback."""
spec = _make_world_spec()
card = render_dataset_card(spec)
assert "event-derived" in card
assert "closed_won" not in card


def test_card_task_manifest_empty_description_uses_generic_fallback() -> None:
"""A TaskManifest with empty description falls back to generic prose."""
spec = _make_world_spec()
task = TaskManifest(
task_id="converted_within_90_days",
label_column="converted_within_90_days",
label_window_days=90,
primary_table="leads",
split=SplitSpec(train=0.7, valid=0.15, test=0.15),
description="",
)
card = render_dataset_card(spec, task_manifest=task)
assert "event-derived" in card
assert "closed_won" not in card


def test_card_non_default_task_via_factory_has_generic_prose() -> None:
"""task_manifest_for_config with non-default task produces generic description."""
spec = _make_world_spec(primary_task="churned_within_60_days", label_window_days=60)
task = task_manifest_for_config("churned_within_60_days", 60)
card = render_dataset_card(spec, task_manifest=task)
assert "`churned_within_60_days`" in card
assert "60-day" in card
assert "closed_won" not in card
Loading