Skip to content

Commit ca42ecc

Browse files
shaypal5claude
andauthored
feat(lifecycle): instructor write_bundle + tasks [LTV-Pn.4b] (#126)
* feat(lifecycle): instructor write_bundle + tasks [LTV-Pn.4b] Second sub-PR of the split LTV-Pn.4. Implements the lifecycle scheme's instructor-mode write path — the first end-to-end lifecycle bundle on disk. write_bundle (research_instructor): - Relational: to_dataframes → write_relational_tables (6 tables). - Tasks: both observation-regime snapshots (calendar + early-pLTV) split into 8 task dirs via the shared render.tasks writer — per regime, 3 pLTV regression tasks (pltv_revenue_{90,365,730}d) + 1 churn classification (churned_within_180d); the early regime prefixed early_. - Dataset card: new schemes/lifecycle/render/dataset_card.py (the lead-scoring card is hard-coupled to the conversion framing; lifecycle renders its own — pLTV regression framing, two regimes, the 8-task table, the mrr_change_full_period trap note). - Feature dictionary over CUSTOMER_SNAPSHOT_FEATURES. - Manifest: build_manifest with generation_scheme=lifecycle, motif_family, and extra_fields = {observation_date, forward_windows_days, early_tenure_weeks}. - write_metadata hook: schemes/lifecycle/render/metadata.py serialises the per-entity latent registry + the motif's mechanism parameters (unwrapping the MappingProxyType weight maps); no hidden graph (lifecycle has none). Difficulty: config.difficulty_params is threaded into both snapshot builders (tested — strong knobs perturb task features, targets untouched). This discharges the threading half of the LTV-Pn.4a pin; recipe-driven *resolution* of difficulty_params is LTV-Po. Safety: student_public is refused (raises NotImplementedError pointing at LTV-Pn.4c) rather than emit a bundle that is not yet snapshot-safe. Tests (11 new): required bundle files; 6 tables; 8 task dirs with train/valid/test + manifest; task-type correctness; manifest scheme + lifecycle fields; metadata files (no graph); full-bundle determinism (SHA-256); difficulty threading; student_public refusal; unpopulated / wrong-scheme bundle rejection. Obsolete stub tests updated. Full suite 1860 passed / 51 skipped; ruff + mypy clean. Known (flagged): validate_bundle is lead-scoring-coupled and errors on a lifecycle bundle — scheme-aware validation is LTV-Pp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lifecycle): per-task single-target splits, no cross-target leak [LTV-Pn.4b] Self-review of the write path found a real task-construction bug: every one of the 8 task dirs received the FULL snapshot, so each task's parquet carried all four targets (ltv_revenue_{90,365,730}d + churned_within_180d). To predict ltv_revenue_90d a consumer could read ltv_revenue_365d (corr ~0.88) and 730d — its own supersets — straight off the row. X = df.drop(columns=[label]) trains on the answer. This is not the intended mrr_change_full_period trap; it is wrong in instructor mode too (these splits are meant to be standalone ML-ready), so it belongs here, not deferred to the public-mode PR. Fix: before splitting, project each task's snapshot to drop every OTHER target column, keeping features + the task's own target + the deliberate mrr_change_full_period trap (leakage_risk but not a target). Each task dir is now a true single-target dataset. Test: test_each_task_split_has_only_its_own_target asserts each task parquet contains exactly its own target among the target columns, and that the trap survives. Full suite 1861 passed / 51 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lifecycle): accurate manifest windows + guard; roadmap + lint [LTV-Pn.4b] Addresses the two Copilot review threads on #126 and the CI lint failure. COPILOT-1 (manifest forward_windows_days vs. actual exported windows): The manifest recorded config.forward_windows_days, but the snapshots/tasks export the fixed snapshots.FORWARD_WINDOWS_DAYS (config is not threaded into the snapshot builder yet). An override would make the manifest disagree with the task dirs, and a shorter override would under-simulate and fail opaquely in the snapshot builder. Fixes both: - The manifest now records FORWARD_WINDOWS_DAYS (the source of truth for what is actually exported), so it is always accurate. - build_world validates config.forward_windows_days == FORWARD_WINDOWS_DAYS and raises InvalidConfigError early with a clear message, converting the opaque late failure into an explicit one. Default configs are unaffected (the Pn.3 consistency test already pins them equal). - New test_rejects_unsupported_forward_windows_override. COPILOT-2: removed the duplicate "Labels" bullet in the LTV-Pn.4b roadmap entry (copy/paste artifact). CI Lint & format: ruff-format leadforge/schemes/lifecycle/__init__.py (the leak-fix edit had dropped a blank line; ruff check passed but format did not). Full suite 1862 passed / 51 skipped; ruff check + format + mypy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fc75146 commit ca42ecc

9 files changed

Lines changed: 628 additions & 33 deletions

File tree

.agent-plan.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,14 @@ split writer + `schemes/lifecycle/tasks.py` task families; discharges the
8282
`LTV-Pc` regression-task-spec leftover) opened as **#124** (merged). `LTV-Pn.4` split into four (build → write → public-safety
8383
→ orchestrator): `LTV-Pn.4a` (`LifecycleScheme.build_world` — deterministic
8484
motif sampling + population + sim + `LifecycleArtifacts`; lifecycle relational
85-
`to_dataframes`; consumes the Pn.3 config fields) opened as **#125**. Next:
86-
`Pn.4b` (instructor `write_bundle` + tasks), `Pn.4c` (student_public
87-
snapshot-safety + CLAUDE.md), `Pn.4d` (shared bundle orchestrator), `LTV-Po`
88-
(recipe).
85+
`to_dataframes`; consumes the Pn.3 config fields) opened as **#125** (merged). `LTV-Pn.4b` (instructor-mode `write_bundle`
86+
first on-disk lifecycle bundle: 6 relational tables + 8 task dirs (both
87+
regimes) + lifecycle dataset card + manifest extra_fields + hidden-truth
88+
metadata; difficulty_params threaded; student_public refused until 4c) opened
89+
as **#126**. Next: `Pn.4c` (student_public snapshot-safety + CLAUDE.md +
90+
recipe-driven difficulty resolution), `Pn.4d` (shared bundle orchestrator),
91+
`LTV-Po` (recipe). Note: `validate_bundle` is lead-scoring-coupled — scheme-
92+
aware validation is `LTV-Pp`.
8993

9094
---
9195

docs/ltv/roadmap.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ protocol + registry, with the package physically reorganized into
4646
| `LTV-M3` | Customer population + lifecycle world | `LTV-Ph`, `LTV-Pi` | #113 (Ph) |
4747
| `LTV-M4` | Lifecycle simulation engine | `LTV-Pj`, `LTV-Pk` | #117 (Pj), #118 (Pk) |
4848
| `LTV-M5` | Customer snapshots + pLTV targets (both regimes) | `LTV-Pl`, `LTV-Pm` | #119 (Pl), #120 (Pm) |
49-
| `LTV-M6` | Register LifecycleScheme + recipe + manifest/version | `LTV-Pn.1…4`, `LTV-Po` | #121 (Pn.1), #122 (Pn.2), #124 (Pn.3), #125 (Pn.4a) |
49+
| `LTV-M6` | Register LifecycleScheme + recipe + manifest/version | `LTV-Pn.1…4`, `LTV-Po` | #121 (Pn.1), #122 (Pn.2), #124 (Pn.3), #125 (Pn.4a), #126 (Pn.4b) |
5050
| `LTV-M7` | Validation + regression-metric calibration | `LTV-Pp` | |
5151
| `LTV-M8` | CLI, notebooks, publish | `LTV-Pq`, `LTV-Pr`, `LTV-Ps` | |
5252

@@ -325,15 +325,22 @@ methods, then public-safety, then the carried orchestrator cleanup:
325325
`write_bundle` still stubbed.
326326
- Tests: determinism, cross-seed motif variability, FK integrity, table shapes.
327327
- Labels: `type: feature`, `layer: api`, `layer: render`
328-
- [ ] **`LTV-Pn.4b`**`feat(lifecycle): write_bundle (instructor) + tasks`.
329-
Instructor-mode `write_bundle`: relational tables; both regime snapshots →
330-
8 task dirs (3 pLTV regression + churn, × 2 regimes) via the shared writer;
331-
dataset card; feature dictionary; manifest with `generation_scheme` +
332-
`observation_date` + windows (`extra_fields`); lifecycle `write_metadata`
333-
hidden-truth hook (latent registry + mechanism summary). First on-disk
334-
lifecycle bundle. **Must resolve `difficulty_params` from the active profile
335-
and thread it into `build_customer_snapshot` (Pn.4a's `build_world` does not —
336-
without this the snapshot distortions never fire and every tier is identical).**
328+
- [x] **`LTV-Pn.4b`**`feat(lifecycle): write_bundle (instructor) + tasks`
329+
(**PR #126**). Instructor-mode `write_bundle` produces the first on-disk
330+
lifecycle bundle: six relational tables; both regime snapshots → 8 task dirs
331+
(3 pLTV regression + churn, × 2 regimes) via the shared writer; a lifecycle
332+
dataset card (`render/dataset_card.py` — the lead-scoring card is too
333+
coupled to reuse); feature dictionary; manifest with `generation_scheme` +
334+
`observation_date` + `forward_windows_days` (`extra_fields`); lifecycle
335+
`write_metadata` hidden-truth hook (latent registry + mechanism summary;
336+
no graph). `config.difficulty_params` is **threaded** into both snapshot
337+
builders (tested), so recipe-resolved difficulty will drive distortions;
338+
recipe-driven *resolution* of `difficulty_params` lands in `LTV-Po`.
339+
`student_public` is **refused** (raises) until `LTV-Pn.4c` adds the
340+
snapshot-safe export — never emit an unsafe public bundle.
341+
- **Flagged:** `validation.bundle_checks.validate_bundle` is lead-scoring-
342+
coupled (applies lead-scoring FK/table/task checks) and errors on a
343+
lifecycle bundle; scheme-aware validation is `LTV-Pp`.
337344
- Labels: `type: feature`, `layer: api`, `layer: render`
338345
- [ ] **`LTV-Pn.4c`**`feat(lifecycle): student_public snapshot-safety`.
339346
Public relational filtering (event tables ≤ cutoff; drop terminal

leadforge/schemes/lifecycle/__init__.py

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
33
The second peer scheme alongside ``lead_scoring``. Its entity rows and FK
44
constraints live here (``entities`` / ``relationships``); the snapshot, feature,
5-
and task definitions live in sibling modules. :meth:`LifecycleScheme.build_world`
6-
is implemented (LTV-Pn.4a); :meth:`write_bundle` / :meth:`write_metadata` are
7-
built out in LTV-Pn.4b–c and currently raise :class:`NotImplementedError`.
5+
and task definitions live in sibling modules. ``build_world`` (LTV-Pn.4a) and
6+
the instructor-mode ``write_bundle`` / ``write_metadata`` (LTV-Pn.4b) are
7+
implemented; the ``student_public`` snapshot-safe export lands in LTV-Pn.4c.
88
"""
99

1010
from __future__ import annotations
@@ -20,11 +20,6 @@
2020
from leadforge.core.models import GenerationConfig, WorldBundle
2121
from leadforge.narrative.spec import NarrativeSpec
2222

23-
_NOT_IMPLEMENTED = (
24-
"the lifecycle (b2b_saas_ltv_v1) write path is not implemented yet; "
25-
"it is built across LTV-Pn.4b–c"
26-
)
27-
2823

2924
def _sample_motif_family(rng: random.Random) -> str:
3025
"""Deterministically pick a retention motif family for this world.
@@ -74,11 +69,26 @@ def build_world(
7469
``narrative.yaml`` will not drive them until ``LTV-Po`` decides
7570
whether the lifecycle scheme should consume the narrative spec.
7671
"""
72+
from leadforge.core.exceptions import InvalidConfigError
7773
from leadforge.core.models import WorldBundle, WorldSpec
7874
from leadforge.core.rng import RNGRoot
7975
from leadforge.schemes.lifecycle.artifacts import LifecycleArtifacts
8076
from leadforge.schemes.lifecycle.engine import simulate_lifecycle
8177
from leadforge.schemes.lifecycle.population import build_customer_population
78+
from leadforge.schemes.lifecycle.snapshots import FORWARD_WINDOWS_DAYS
79+
80+
# config.forward_windows_days is not yet threaded into the snapshot
81+
# builder, which exports the fixed FORWARD_WINDOWS_DAYS targets. Reject
82+
# an override now (clear, early) rather than emit a bundle whose manifest
83+
# disagrees with its task dirs, or under-simulate and fail opaquely later.
84+
# Threading config-driven windows through is tracked for a later step.
85+
if tuple(config.forward_windows_days) != tuple(FORWARD_WINDOWS_DAYS):
86+
raise InvalidConfigError(
87+
f"config.forward_windows_days={tuple(config.forward_windows_days)} differs "
88+
f"from the lifecycle scheme's exported windows {tuple(FORWARD_WINDOWS_DAYS)}; "
89+
"config-driven forward windows are not yet supported (the snapshot builder "
90+
"exports the fixed set). Use the default until that wiring lands."
91+
)
8292

8393
motif_rng = RNGRoot(config.seed).child("lifecycle_motif")
8494
motif_family = _sample_motif_family(motif_rng)
@@ -112,10 +122,163 @@ def write_bundle(
112122
path: str,
113123
generation_timestamp: str | None = None,
114124
) -> None:
115-
raise NotImplementedError(_NOT_IMPLEMENTED)
125+
"""Serialise a lifecycle *bundle* to *path* (instructor mode).
126+
127+
Writes the six relational tables, both observation regimes' snapshots
128+
split into 8 task directories (3 pLTV regression + 1 churn
129+
classification per regime, the early regime prefixed ``early_``), a
130+
dataset card, the feature dictionary, the hidden-truth ``metadata/``
131+
(via :meth:`write_metadata`), and the manifest (recording
132+
``generation_scheme`` + ``observation_date`` + the forward windows).
133+
134+
``config.difficulty_params`` is threaded into both snapshot builders —
135+
when set (LTV-Po resolves it from the recipe profile), it drives the
136+
snapshot distortions.
137+
138+
Only ``research_instructor`` mode is supported here. The
139+
``student_public`` snapshot-safety projection (event-table cutoff
140+
filtering, terminal-column drops, per-task target projection) lands in
141+
LTV-Pn.4c; until then this refuses to write a public bundle rather than
142+
emit one that is not snapshot-safe.
143+
"""
144+
from pathlib import Path
145+
146+
from leadforge.core.enums import ExposureMode
147+
from leadforge.exposure.modes import apply_exposure
148+
from leadforge.render.manifests import build_manifest, write_manifest
149+
from leadforge.render.relational_io import write_relational_tables
150+
from leadforge.render.tasks import write_task_splits
151+
from leadforge.schema.dictionaries import write_feature_dictionary
152+
from leadforge.schemes.lifecycle.artifacts import LifecycleArtifacts
153+
from leadforge.schemes.lifecycle.features import CUSTOMER_SNAPSHOT_FEATURES
154+
from leadforge.schemes.lifecycle.render.dataset_card import render_lifecycle_dataset_card
155+
from leadforge.schemes.lifecycle.render.relational import to_dataframes
156+
from leadforge.schemes.lifecycle.snapshots import (
157+
FORWARD_WINDOWS_DAYS,
158+
build_customer_snapshot,
159+
build_early_pltv_snapshot,
160+
)
161+
from leadforge.schemes.lifecycle.tasks import (
162+
CALENDAR_REGIME,
163+
EARLY_REGIME,
164+
lifecycle_task_manifests,
165+
)
166+
167+
artifacts = bundle.artifacts
168+
if not isinstance(artifacts, LifecycleArtifacts):
169+
raise RuntimeError(
170+
"WorldBundle is not populated with lifecycle artifacts. "
171+
"Call Generator.generate() / build_world() first."
172+
)
173+
config = bundle.spec.config
174+
if config.exposure_mode is not ExposureMode.research_instructor:
175+
raise NotImplementedError(
176+
f"lifecycle write_bundle currently supports only "
177+
f"research_instructor; {config.exposure_mode.value!r} (snapshot-safe "
178+
"public export) lands in LTV-Pn.4c"
179+
)
180+
181+
population = artifacts.population
182+
sim = artifacts.simulation_result
183+
root = Path(path)
184+
root.mkdir(parents=True, exist_ok=True)
185+
186+
# 1. Relational tables → tables/
187+
dfs = to_dataframes(sim, population)
188+
table_row_counts = write_relational_tables(dfs, root / "tables")
189+
190+
# 2. Both regime snapshots → 8 task directories.
191+
# difficulty_params (None until LTV-Po resolves it) drives distortions.
192+
snapshots = {
193+
CALENDAR_REGIME: build_customer_snapshot(
194+
population, sim, difficulty_params=config.difficulty_params, seed=config.seed
195+
),
196+
EARLY_REGIME: build_early_pltv_snapshot(
197+
population,
198+
sim,
199+
early_tenure_weeks=config.early_tenure_weeks,
200+
difficulty_params=config.difficulty_params,
201+
seed=config.seed,
202+
),
203+
}
204+
# Each task is a standalone single-target split: drop every OTHER
205+
# target column so a task's parquet cannot leak the answer's siblings
206+
# (e.g. ltv_revenue_730d ⊇ ltv_revenue_90d). The deliberate
207+
# mrr_change_full_period trap (leakage_risk but not a target) is kept.
208+
all_target_cols = {f.name for f in CUSTOMER_SNAPSHOT_FEATURES if f.is_target}
209+
task_row_counts: dict[str, dict[str, int]] = {}
210+
all_tasks = []
211+
for regime, snapshot in snapshots.items():
212+
for task in lifecycle_task_manifests(regime):
213+
other_targets = [
214+
c for c in all_target_cols - {task.label_column} if c in snapshot.columns
215+
]
216+
task_df = snapshot.drop(columns=other_targets)
217+
counts = write_task_splits(task_df, root / "tasks", seed=config.seed, task=task)
218+
task_row_counts[task.task_id] = counts
219+
all_tasks.append(task)
220+
221+
# 3. Dataset card + feature dictionary
222+
(root / "dataset_card.md").write_text(
223+
render_lifecycle_dataset_card(
224+
bundle.spec,
225+
table_counts=table_row_counts,
226+
tasks=tuple(all_tasks),
227+
observation_date=population.observation_date,
228+
)
229+
)
230+
write_feature_dictionary(
231+
root / "feature_dictionary.csv", features=tuple(CUSTOMER_SNAPSHOT_FEATURES)
232+
)
233+
234+
# 4. Exposure metadata (delegates hidden truth to write_metadata)
235+
apply_exposure(bundle, root, config.exposure_mode)
236+
237+
# 5. Manifest
238+
manifest = build_manifest(
239+
config=config,
240+
generation_scheme=self.name,
241+
motif_family=artifacts.motif_family,
242+
table_row_counts=table_row_counts,
243+
task_row_counts=task_row_counts,
244+
bundle_root=root,
245+
generation_timestamp=generation_timestamp,
246+
extra_fields={
247+
"observation_date": population.observation_date,
248+
# The actual exported target windows (source of truth), not
249+
# config.forward_windows_days — build_world rejects any mismatch.
250+
"forward_windows_days": list(FORWARD_WINDOWS_DAYS),
251+
"early_tenure_weeks": config.early_tenure_weeks,
252+
},
253+
)
254+
write_manifest(manifest, root)
116255

117256
def write_metadata(self, bundle: WorldBundle, meta_dir: Path) -> None:
118-
raise NotImplementedError(_NOT_IMPLEMENTED)
257+
"""Write the lifecycle hidden-truth files into *meta_dir*.
258+
259+
Called by :func:`leadforge.exposure.modes.apply_exposure` after the
260+
shared ``world_spec.json``. The lifecycle scheme has no hidden graph;
261+
its latent truth is the per-entity latent registry and the
262+
motif-derived mechanism parameters.
263+
"""
264+
import json
265+
266+
from leadforge.schemes.lifecycle.artifacts import LifecycleArtifacts
267+
from leadforge.schemes.lifecycle.render.metadata import (
268+
latent_registry_dict,
269+
mechanism_summary_dict,
270+
)
271+
272+
artifacts = bundle.artifacts
273+
if not isinstance(artifacts, LifecycleArtifacts):
274+
raise RuntimeError("WorldBundle is not populated with lifecycle artifacts.")
275+
276+
(meta_dir / "latent_registry.json").write_text(
277+
json.dumps(latent_registry_dict(artifacts.population.latent_state), indent=2)
278+
)
279+
(meta_dir / "mechanism_summary.json").write_text(
280+
json.dumps(mechanism_summary_dict(artifacts.motif_family), indent=2)
281+
)
119282

120283

121284
LIFECYCLE_SCHEME = LifecycleScheme()
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Dataset-card renderer for the lifecycle (pLTV) scheme.
2+
3+
The lead-scoring card (:func:`leadforge.narrative.dataset_card.render_dataset_card`)
4+
is hard-coupled to the lead-scoring framing (binary conversion label, single
5+
task, narrative-driven firmographics), so the lifecycle scheme renders its own.
6+
Kept deliberately concise for LTV-Pn.4b; richer prose can follow.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import TYPE_CHECKING
12+
13+
if TYPE_CHECKING:
14+
from leadforge.core.models import WorldSpec
15+
from leadforge.schema.tasks import TaskManifest
16+
17+
__all__ = ["render_lifecycle_dataset_card"]
18+
19+
20+
def render_lifecycle_dataset_card(
21+
world_spec: WorldSpec,
22+
*,
23+
table_counts: dict[str, int],
24+
tasks: tuple[TaskManifest, ...],
25+
observation_date: str,
26+
) -> str:
27+
"""Return a Markdown dataset card for a lifecycle (pLTV) bundle."""
28+
cfg = world_spec.config
29+
tier = (str(cfg.difficulty) if cfg.difficulty else "unknown").capitalize()
30+
31+
lines: list[str] = [
32+
f"# B2B SaaS pLTV Dataset — {tier} Tier",
33+
"",
34+
"## What this is",
35+
"",
36+
"A synthetic B2B SaaS customer base simulated week by week from "
37+
"acquisition through retention, expansion, and churn. The prediction "
38+
"task is **predicted lifetime value (pLTV)**: a continuous, "
39+
"zero-inflated, right-skewed regression target — forecast each "
40+
"customer's future gross revenue over a fixed forward window. Customer "
41+
"churn is provided as a secondary classification label.",
42+
"",
43+
"## Two observation regimes",
44+
"",
45+
"- **Calendar-anchored (standard)** — every customer observed at the "
46+
f"fixed observation date (`{observation_date}`); tenure varies from "
47+
"cold to mature. Task ids: `pltv_revenue_*`, `churned_within_180d`.",
48+
"- **Tenure-anchored (early-pLTV)** — every customer observed at a "
49+
f"fixed short tenure (`customer_start + {cfg.early_tenure_weeks}w`); the "
50+
"genuine cold-start case. Task ids prefixed `early_`.",
51+
"",
52+
"## Tasks",
53+
"",
54+
"| task_id | type | target | window (days) |",
55+
"|---|---|---|---|",
56+
]
57+
for t in tasks:
58+
lines.append(
59+
f"| `{t.task_id}` | {t.task_type} | `{t.label_column}` | {t.label_window_days} |"
60+
)
61+
62+
lines += [
63+
"",
64+
"## Relational tables",
65+
"",
66+
"| table | rows |",
67+
"|---|---|",
68+
]
69+
for name, count in table_counts.items():
70+
lines.append(f"| `{name}` | {count} |")
71+
72+
lines += [
73+
"",
74+
"## Leakage trap",
75+
"",
76+
"`mrr_change_full_period` is a deliberate trap: it is computed through "
77+
"the end of simulation, so post-cutoff expansions inflate it. Use "
78+
"`mrr_change_at_snapshot` (computed strictly at the cutoff) instead.",
79+
"",
80+
"## Reproducibility",
81+
"",
82+
f"- Recipe: `{cfg.recipe_id}`",
83+
f"- Seed: `{cfg.seed}`",
84+
f"- Scheme: `{world_spec.scheme}`",
85+
"",
86+
"Deterministic given (recipe, config, seed, package version).",
87+
"",
88+
]
89+
return "\n".join(lines)

0 commit comments

Comments
 (0)