From afd7c40c1aa91ae0e591cb79c94d8524c5c6f32d Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Mon, 7 Sep 2026 00:45:32 +0530 Subject: [PATCH 1/4] feat(runtime): soul forking with lineage A soul can now produce a child soul. `Soul.fork()` mints a new DID, records `parent_did` and bumps `generation`, deep-copies the DNA, and drifts each of the five OCEAN traits by an independent uniform delta clamped to 0..1. Lineage is a separate axis from reincarnation. `incarnation` and `previous_lives` track the same soul living again; `parent_did` and `generation` track a new soul descended from another. Both fields default, so souls written before this change load unchanged. Memory inheritance is opt-in per tier and defaults to core + procedural, because how-tos passing down is how culture spreads. Episodic is always empty on the child even when the caller asks for it. Bonds, role, the trust chain and the parent's mutation log stay with the parent. `EvolutionConfig.mutation_rate` has been serialized in every soul since the evolution system shipped and nothing read it. It is now the default drift width. Known consequence: `immutable_traits` defaults to `["personality", "core_values"]` and fork honours it, so a default-configured parent forks an OCEAN clone whatever the drift. `soul fork` warns and reports the frozen traits in `--json`; drop "personality" from the parent's `immutable_traits` to let children diverge. Adds `soul fork --child ` with --drift, --inherit, --charter, --output and --json. `soul inspect` and `soul status` show Parent and Generation for forked souls only. `public_profile()` carries the lineage. --- CHANGELOG.md | 10 + docs/api-reference.md | 46 ++++ docs/cli-reference.md | 37 +++ schemas/Identity.schema.json | 17 ++ schemas/SoulConfig.schema.json | 17 ++ schemas/soul-protocol.schema.json | 17 ++ src/soul_protocol/cli/main.py | 133 +++++++++++ src/soul_protocol/runtime/soul.py | 204 +++++++++++++++- src/soul_protocol/runtime/types.py | 12 + tests/test_fork.py | 358 +++++++++++++++++++++++++++++ 10 files changed, 850 insertions(+), 1 deletion(-) create mode 100644 tests/test_fork.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e2bbfc00..39da4b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Reproduction — soul forking with lineage + +- **`Soul.fork(child_name, *, drift, inherit, charter)`** — a soul can now produce a child soul. The child gets a fresh DID, `identity.parent_did` pointing at the parent, and `identity.generation = parent.generation + 1`. Its DNA is deep-copied and the five OCEAN traits each drift by an independent uniform delta in `[-drift, +drift]`, clamped to the schema's 0..1. Archetype, core values and the evolution *rules* carry over; the parent's mutation log does not. +- **`Identity.parent_did` and `Identity.generation`** — new fields, both defaulted, so souls written before this release load unchanged. Lineage (parent → child) is a separate axis from reincarnation (`incarnation` / `previous_lives`, the same soul living again); a soul can carry both. +- **Memory inheritance is opt-in per tier** — `inherit` defaults to `("core", "procedural")`, because how-tos passing down is how culture spreads. `semantic` and `social` pass only when named. **Episodic memory is always empty on the child**, even when the caller asks for it: a child does not remember its parent's life. Bonds, `role`, the trust chain, the self-model and the knowledge graph stay with the parent. `role` in particular is deliberately dropped so a `root` governance soul cannot accidentally hand its undeletable status to a child. +- **`EvolutionConfig.mutation_rate` finally has a reader.** The field has been serialized in every soul since the evolution system shipped and nothing consumed it; it is now the default drift width for `fork()`. +- **Known consequence:** `EvolutionConfig.immutable_traits` defaults to `["personality", "core_values"]`, and `fork()` honours it, so a default-configured parent produces an **OCEAN clone** no matter what `drift` is set to. `soul fork` prints a warning and reports the frozen traits in `--json`; `soul_protocol.runtime.soul.frozen_ocean_traits()` exposes the same check. Drop `"personality"` from the parent's `immutable_traits` to let children diverge. +- **New CLI: `soul fork --child `** with `--drift`, `--inherit`, `--charter`, `--output/-o` and `--json`. +- **Lineage read path:** `Soul.public_profile()` now carries `parent_did` and `generation` (unlike `previous_lives`, which stays private — a parent DID is a link others walk). `soul inspect` and `soul status` show `Parent` / `Generation` lines for forked souls only, so output for unforked souls is unchanged. + ### Architecture debt — extract health, rename list (#288) - **Refactor:** Extracted duplicated health-audit and cleanup logic from `cli/main.py` and `mcp/server.py` into a new shared module `runtime/health.py`. Both entry points now delegate to `audit_health()`, `plan_cleanup()`, and `execute_cleanup()`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 25e530fe..91918488 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -173,6 +173,46 @@ soul = await Soul.awaken("./kavi.soul") soul = await Soul.awaken(Path("config.yaml"), engine=my_engine) ``` +#### `Soul.fork()` + +```python +async def fork( + self, + child_name: str, + *, + drift: float | None = None, + inherit: Sequence[str] = ("core", "procedural"), + charter: str | None = None, +) -> Soul +``` + +Fork a **child** soul from this one — reproduction with lineage. The child gets a fresh DID, `identity.parent_did` pointing at this soul, and `identity.generation = parent.generation + 1`. + +This is a different axis from `Soul.reincarnate()`. Reincarnation is the *same* soul living again (`incarnation` / `previous_lives`); a fork is a *new* soul descended from another. A soul can carry both. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `child_name` | `str` | required | Name for the child | +| `drift` | `float \| None` | `None` | Half-width of the per-trait OCEAN mutation. Defaults to the parent's `EvolutionConfig.mutation_rate`. `0` copies OCEAN exactly. | +| `inherit` | `Sequence[str]` | `("core", "procedural")` | Memory tiers to carry forward: `core`, `semantic`, `procedural`, `social` | +| `charter` | `str \| None` | `None` | Charter written by the parent; stored in the child's core memory with parent attribution | + +**Returns:** `Soul` — ready to `save_local()` / `export()`. + +**Raises:** `ValueError` if `drift` is negative or `inherit` names an unknown tier. + +What crosses the gap: DNA (deep-copied, then each of the five OCEAN traits drifts by an independent uniform delta in `[-drift, +drift]`, clamped to 0..1), the archetype, the core values, the evolution *rules*, and the requested memory tiers. + +What does not: **episodic memory is always empty on the child**, even when `inherit` names it — a child does not remember its parent's life. Bonds, role, trust chain, mutation history, self-model and knowledge graph also stay with the parent. + +```python +child = await parent.fork("Vale", drift=0.08, charter="Keep the well open.") +assert child.identity.parent_did == parent.did +assert child.identity.generation == parent.identity.generation + 1 +``` + +> **Drift is gated by `immutable_traits`.** The default `EvolutionConfig.immutable_traits` contains `"personality"`, which freezes all five OCEAN traits — a default-configured parent produces an OCEAN clone regardless of `drift`. Drop `"personality"` from the parent's `immutable_traits` to let children diverge. `soul_protocol.runtime.soul.frozen_ocean_traits(immutable_traits)` reports which traits are frozen. + #### `Soul.from_markdown()` ```python @@ -1036,6 +1076,12 @@ A soul's unique identity with cryptographic DID. | `origin_story` | `str` | `""` | Persona / origin text | | `prime_directive` | `str` | `""` | Top-level directive | | `core_values` | `list[str]` | `[]` | Values for significance scoring | +| `incarnation` | `int` | `1` | Rebirth counter — bumped by `reincarnate()` | +| `previous_lives` | `list[str]` | `[]` | DIDs of this soul's earlier incarnations | +| `parent_did` | `str \| None` | `None` | DID of the soul this one was forked from | +| `generation` | `int` | `1` | Depth in the fork lineage — bumped by `fork()` | + +Lineage (`parent_did` / `generation`) and rebirth (`incarnation` / `previous_lives`) are separate axes: the first tracks a new soul descended from another, the second tracks the same soul living again. ### DNA / Personality diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7a345818..4a88e65b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1250,6 +1250,43 @@ soul evolve .soul/ --reject abc123 --- +### `soul fork` + +Fork a child Soul from a parent — reproduction with lineage. The child gets a new DID, `parent_did` pointing at the parent, and `generation = parent + 1`. Its OCEAN traits drift from the parent's by an independent random delta per trait. + +```bash +soul fork aria.soul --child Vale +soul fork aria.soul --child Vale --drift 0.08 --inherit core,procedural +soul fork .soul/ --child Vale --charter "Keep the well open" --json +``` + +**Arguments:** + +| Argument | Required | Description | +|----------|----------|-------------| +| `PATH` | Yes | Path to the parent soul file or `.soul/` directory. | + +**Options:** + +| Option | Description | +|--------|-------------| +| `--child TEXT` | **Required.** Name for the child soul. | +| `--drift FLOAT` | OCEAN mutation half-width per trait. Defaults to the parent's `evolution.mutation_rate`. `0` copies OCEAN exactly. | +| `--inherit TEXT` | Comma-separated memory tiers to inherit: `core`, `semantic`, `procedural`, `social`. Default `core,procedural`. | +| `--charter TEXT` | Charter written by the parent; stored in the child's core memory with parent attribution. | +| `--output, -o PATH` | Output path (default `./.soul`). An existing directory is saved into. | +| `--json` | Emit machine-readable JSON instead of the rich panel. | + +Episodic memory is **never** inherited — a child does not remember its parent's life, even when `--inherit` names `episodic`. Bonds, role, trust chain and mutation history stay with the parent too. + +`--json` emits `child`, `did`, `parent_did`, `generation`, `drift`, `inherited`, `frozen_traits`, `ocean` and `output`. + +> **Drift is gated by `immutable_traits`.** The default evolution config marks `personality` immutable, which freezes all five OCEAN traits — so a default parent forks an OCEAN clone whatever `--drift` says. The command prints a warning (and lists them in `frozen_traits`) when that happens. Drop `personality` from the parent's `evolution.immutable_traits` to let children diverge. + +Lineage shows up in `soul inspect` and `soul status` as `Parent` / `Generation` lines, and in `Soul.public_profile()` as `parent_did` / `generation`. + +--- + ### `soul evaluate` Evaluate an interaction against a rubric. Scores the interaction, stores learning as procedural memory, and adjusts skill XP based on the score. diff --git a/schemas/Identity.schema.json b/schemas/Identity.schema.json index c37a07dc..3fea3431 100644 --- a/schemas/Identity.schema.json +++ b/schemas/Identity.schema.json @@ -130,6 +130,23 @@ }, "title": "Previous Lives", "type": "array" + }, + "parent_did": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Did" + }, + "generation": { + "default": 1, + "title": "Generation", + "type": "integer" } }, "required": [ diff --git a/schemas/SoulConfig.schema.json b/schemas/SoulConfig.schema.json index 123570e4..3d1bd4fa 100644 --- a/schemas/SoulConfig.schema.json +++ b/schemas/SoulConfig.schema.json @@ -346,6 +346,23 @@ }, "title": "Previous Lives", "type": "array" + }, + "parent_did": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Did" + }, + "generation": { + "default": 1, + "title": "Generation", + "type": "integer" } }, "required": [ diff --git a/schemas/soul-protocol.schema.json b/schemas/soul-protocol.schema.json index e41f0e20..d59d228e 100644 --- a/schemas/soul-protocol.schema.json +++ b/schemas/soul-protocol.schema.json @@ -351,6 +351,23 @@ }, "title": "Previous Lives", "type": "array" + }, + "parent_did": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Did" + }, + "generation": { + "default": 1, + "title": "Generation", + "type": "integer" } }, "required": [ diff --git a/src/soul_protocol/cli/main.py b/src/soul_protocol/cli/main.py index 12e8c019..7097541b 100644 --- a/src/soul_protocol/cli/main.py +++ b/src/soul_protocol/cli/main.py @@ -1,4 +1,8 @@ # cli/main.py — Click CLI for the Soul Protocol (org + user groups + runtime commands) +# Updated: 2026-09-07 (terrarium) — New `soul fork --child ` +# command (reproduction with lineage): --drift, --inherit, --charter, +# --output, --json. `soul inspect` and `soul status` now show Parent / +# Generation for forked souls. # Updated: 2026-08-04 (#294) — Wires `--password` flag into `soul export`, # `soul inspect`, and `soul unpack` for AES-256-GCM encryption at rest. # Password is prompt-only (is_flag=True) so it never leaks into shell history. @@ -542,6 +546,11 @@ async def _inspect(): ] if soul.identity.core_values: identity_lines.append(f"Values {', '.join(soul.identity.core_values)}") + # Lineage — only shown for forked souls, so unforked output is unchanged. + if soul.identity.parent_did: + identity_lines.append(f"Parent [dim]{soul.identity.parent_did}[/dim]") + if soul.identity.generation > 1: + identity_lines.append(f"Generation {soul.identity.generation}") identity_panel = Panel( "\n".join(identity_lines), @@ -683,6 +692,13 @@ async def _status(): f" Memories {soul.memory_count}", ] + # Lineage — only for forked souls, so unforked output is unchanged. + if soul.identity.parent_did: + lines.append( + f" Lineage gen {soul.identity.generation}, " + f"child of [dim]{soul.identity.parent_did}[/dim]" + ) + # Multi-user bond display when more than one user has a bond. bonded_users = soul.bonded_users if bonded_users: @@ -3137,6 +3153,123 @@ async def _evolve(): asyncio.run(_evolve()) +@cli.command("fork") +@click.argument("path", type=click.Path(exists=True)) +@click.option("--child", "child_name", required=True, help="Name for the child soul") +@click.option( + "--drift", + type=float, + default=None, + help="OCEAN mutation half-width per trait (default: parent's evolution.mutation_rate)", +) +@click.option( + "--inherit", + default="core,procedural", + show_default=True, + help="Comma-separated memory tiers to inherit: core, semantic, procedural, social. " + "Episodic is never inherited.", +) +@click.option("--charter", type=str, default=None, help="Charter written by the parent") +@click.option( + "--output", + "-o", + type=click.Path(), + default=None, + help="Output path (default: ./.soul). An existing directory is saved into.", +) +@click.option("--json", "as_json", is_flag=True, default=False, help="Emit JSON") +def fork_cmd(path, child_name, drift, inherit, charter, output, as_json): + """Fork a child Soul from a parent — reproduction with lineage. + + The child gets a new DID, ``parent_did`` pointing at the parent, and + ``generation = parent + 1``. Its OCEAN traits drift from the parent's by + an independent random delta per trait. Episodic memory always starts + empty — a child does not remember its parent's life. + + \b + Examples: + soul fork aria.soul --child Vale + soul fork aria.soul --child Vale --drift 0.08 --inherit core,procedural + soul fork .soul/ --child Vale --charter "Keep the well open" --json + """ + if drift is not None and drift < 0: + raise click.BadParameter("--drift must be >= 0", param_hint="--drift") + + tiers = [t.strip() for t in inherit.split(",") if t.strip()] + + async def _fork(): + from soul_protocol.runtime.soul import frozen_ocean_traits + + parent = await _awaken_or_fail(path) + try: + child = await parent.fork( + child_name, + drift=drift, + inherit=tiers, + charter=charter, + ) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) from exc + + immutable = parent.serialize().evolution.immutable_traits + frozen = frozen_ocean_traits(immutable) + effective_drift = parent.serialize().evolution.mutation_rate if drift is None else drift + + out = output or f"./{_safe_name(child.name)}.soul" + if Path(out).is_dir(): + await child.save_local(out) + else: + await child.export(out, include_keys=True) + + p = child.dna.personality + if as_json: + click.echo( + json.dumps( + { + "child": child.name, + "did": child.did, + "parent_did": child.identity.parent_did, + "generation": child.identity.generation, + "drift": effective_drift, + "inherited": sorted(t for t in tiers if t != "episodic"), + "frozen_traits": frozen, + "ocean": { + "openness": p.openness, + "conscientiousness": p.conscientiousness, + "extraversion": p.extraversion, + "agreeableness": p.agreeableness, + "neuroticism": p.neuroticism, + }, + "output": str(out), + }, + indent=2, + ) + ) + return + + console.print( + f"[green]Forked[/green] [bold]{child.name}[/bold] ({child.did})\n" + f" Parent {child.identity.parent_did}\n" + f" Generation {child.identity.generation}\n" + f" Drift {effective_drift}\n" + f" Inherited {', '.join(sorted(t for t in tiers if t != 'episodic')) or 'nothing'}" + ) + console.print( + f"[dim]OCEAN: O={p.openness:.2f} C={p.conscientiousness:.2f} " + f"E={p.extraversion:.2f} A={p.agreeableness:.2f} N={p.neuroticism:.2f}[/dim]" + ) + if frozen: + console.print( + f"[yellow]No OCEAN drift:[/yellow] {', '.join(frozen)} frozen by " + f"evolution.immutable_traits ({', '.join(immutable)}). " + "Drop 'personality' from the parent's immutable_traits to let children diverge." + ) + console.print(f"[dim]Saved to {out}[/dim]") + + asyncio.run(_fork()) + + @cli.command("evaluate") @click.argument("path", type=click.Path(exists=True)) @click.option("--user-input", "user_input", required=True, help="User's message") diff --git a/src/soul_protocol/runtime/soul.py b/src/soul_protocol/runtime/soul.py index 7d736938..6a37154d 100644 --- a/src/soul_protocol/runtime/soul.py +++ b/src/soul_protocol/runtime/soul.py @@ -172,6 +172,11 @@ # Previously, _dspy_processor was stored on Soul but never used during observe. # Updated: 2026-03-10 — Added forget(), forget_entity(), forget_before() for # GDPR-compliant memory deletion. Renamed old forget(memory_id) to forget_by_id(). +# Updated: 2026-09-07 (terrarium) — Added fork() for reproduction with lineage. +# A fork is a NEW soul descended from this one (parent_did / generation), +# distinct from reincarnate() which is the SAME soul living again. OCEAN +# traits drift on inheritance; the drift default is the first real consumer +# of EvolutionConfig.mutation_rate. public_profile() now carries lineage. # Updated: feat/soul-encryption — Re-raise SoulDecryptionError without wrapping # alongside SoulEncryptedError in awaken() exception handling. # Updated: feat/soul-encryption — Added password parameter to awaken() and export() @@ -213,7 +218,8 @@ from __future__ import annotations import logging -from collections.abc import Callable +import random +from collections.abc import Callable, Sequence from datetime import datetime from pathlib import Path from typing import Any @@ -292,6 +298,64 @@ _RECALL_RELEVANCE_FLOOR: float = 0.0 +# --------------------------------------------------------------------------- +# Reproduction (terrarium) — see Soul.fork() +# --------------------------------------------------------------------------- +# The five OCEAN traits, in DNA order. Their dot-paths under DNA are +# "personality.", so EvolutionConfig.immutable_traits gates them via +# the "personality" category — exactly the rule EvolutionManager.propose() +# applies to in-life mutations. +OCEAN_TRAITS: tuple[str, ...] = ( + "openness", + "conscientiousness", + "extraversion", + "agreeableness", + "neuroticism", +) +# Memory tiers a child may inherit. Episodic is absent on purpose: a child +# never wakes up with its parent's episodes. +FORKABLE_TIERS: tuple[str, ...] = ("core", "semantic", "procedural", "social") + + +def frozen_ocean_traits(immutable_traits: Sequence[str]) -> list[str]: + """Return the OCEAN traits that ``immutable_traits`` forbids drifting. + + Note the default ``EvolutionConfig.immutable_traits`` contains + ``"personality"``, so a default-configured soul freezes **all five** + traits and :meth:`Soul.fork` produces an OCEAN clone. Drop + ``"personality"`` from the parent's ``immutable_traits`` to let children + diverge. + """ + return [ + trait + for trait in OCEAN_TRAITS + if "personality" in immutable_traits or f"personality.{trait}" in immutable_traits + ] + + +def _drift_ocean( + parent: Personality, + width: float, + immutable_traits: Sequence[str], +) -> Personality: + """Copy ``parent`` with each mutable OCEAN trait nudged and clamped. + + Each trait gets its own uniform delta in ``[-width, +width]``; the result + is clamped to the schema's 0..1 range. Traits frozen by + ``immutable_traits`` are copied through untouched. + """ + child = parent.model_copy(deep=True) + if width == 0: + return child + frozen = set(frozen_ocean_traits(immutable_traits)) + for trait in OCEAN_TRAITS: + if trait in frozen: + continue + drifted = getattr(parent, trait) + random.uniform(-width, width) + setattr(child, trait, min(1.0, max(0.0, drifted))) + return child + + # --------------------------------------------------------------------------- # Engine resolution helper # --------------------------------------------------------------------------- @@ -1067,6 +1131,138 @@ async def reincarnate( ) return soul + async def fork( + self, + child_name: str, + *, + drift: float | None = None, + inherit: Sequence[str] = ("core", "procedural"), + charter: str | None = None, + ) -> Soul: + """Fork a child soul from this one — reproduction with lineage. + + A fork is a **new soul descended from this one**, not this soul living + again. It gets a fresh DID, ``parent_did`` pointing at this soul, and + ``generation = parent.generation + 1``. Contrast + :meth:`reincarnate`, which is the same soul in a new life and tracks + ``incarnation`` / ``previous_lives`` instead. + + What crosses the gap: + + - **DNA** is deep-copied, then the five OCEAN traits each drift by an + independent uniform delta in ``[-drift, +drift]``, clamped to 0..1. + - **Archetype and core values** are inherited verbatim. + - **Memory** is inherited per ``inherit``. Episodic memory is *always* + empty on the child — a child does not remember its parent's life, + even when the caller asks for it. ``semantic`` and ``social`` are + inherited only when named explicitly; ``core`` and ``procedural`` + are the default, because how-tos passing down is how culture spreads. + - **Nothing else.** No bonds, no role, no trust chain, no mutation + history, no self-model, no knowledge graph — those belong to the + parent's life, and a ``role="root"`` child would inherit the + undeletable-governance-soul guard by accident. + + Args: + child_name: Name for the child soul. + drift: Half-width of the per-trait OCEAN mutation. Defaults to the + parent's ``EvolutionConfig.mutation_rate``. ``0`` reproduces + the parent's OCEAN exactly. + inherit: Memory tiers to carry forward. Valid names are ``core``, + ``semantic``, ``procedural``, ``social``. ``episodic`` is + accepted and ignored (see above); anything else raises. + charter: Optional charter written *by this soul for the child*. + Stored in the child's core memory, attributed to the parent. + + Returns: + A fully valid child Soul, ready to ``save_local()`` / ``export()``. + + Raises: + ValueError: If ``drift`` is negative or ``inherit`` names an + unknown tier. + """ + parent_evolution = self._evolution.config + + # First real consumer of EvolutionConfig.mutation_rate. The field has + # been carried in the config since the evolution system shipped and + # nothing has ever read it — reproduction is what it was waiting for. + drift_width = parent_evolution.mutation_rate if drift is None else drift + if drift_width < 0: + raise ValueError(f"drift must be >= 0, got {drift_width}.") + + requested = [str(tier).strip().lower() for tier in inherit] + unknown = [t for t in requested if t not in FORKABLE_TIERS and t != "episodic"] + if unknown: + raise ValueError( + f"Unknown memory tier(s) for fork: {', '.join(sorted(unknown))}. " + f"Valid tiers: {', '.join(FORKABLE_TIERS)}." + ) + if "episodic" in requested: + logger.warning( + "fork(): episodic memory is never inherited — dropping it from inherit=%s", + requested, + ) + tiers = {t for t in requested if t in FORKABLE_TIERS} + + child_dna = self._dna.model_copy(deep=True) + child_dna.personality = _drift_ocean( + self._dna.personality, + drift_width, + parent_evolution.immutable_traits, + ) + + # role deliberately not inherited: a "root" org soul's child would + # otherwise be undeletable. Bonds are not inherited either — a bond is + # the parent's relationship, not the child's. + identity = Identity( + did=generate_did(child_name), + name=child_name, + archetype=self._identity.archetype, + core_values=list(self._identity.core_values), + parent_did=self._identity.did, + generation=self._identity.generation + 1, + ) + + config = SoulConfig( + identity=identity, + dna=child_dna, + # Inherit the evolution *rules*, not the parent's mutation log. + evolution=parent_evolution.model_copy(deep=True, update={"history": [], "pending": []}), + lifecycle=LifecycleState.ACTIVE, + ) + + soul = type(self)(config) + + parent_memory = self._memory.to_dict() + inherited: dict[str, Any] = {} + if "core" in tiers: + inherited["core"] = parent_memory.get("core", {}) + for tier in ("semantic", "procedural", "social"): + if tier in tiers: + inherited[tier] = parent_memory.get(tier, []) + if inherited: + soul._memory = MemoryManager.from_dict( + inherited, + config.memory, + core_values=config.identity.core_values, + personality=config.dna.personality, + ) + + core = soul.get_core_memory() + persona = core.persona or f"I am {child_name}." + if charter: + persona = f"{persona}\n\nCharter (written by {self.name}, {self.did}):\n{charter}" + soul._memory.set_core(persona=persona, human=core.human) + + logger.info( + "Soul forked: child=%s, generation=%d, parent_did=%s, child_did=%s, drift=%.4f", + child_name, + identity.generation, + identity.parent_did, + identity.did, + drift_width, + ) + return soul + @classmethod async def awaken( cls, @@ -1255,6 +1451,12 @@ def public_profile(self) -> dict[str, Any]: "values": list(self._identity.core_values), "ocean": ocean_summary, "skills": skill_names, + # Lineage is public: a parent DID is how any caller walks the + # ancestry chain (fork -> fork -> fork). Deliberately unlike + # ``previous_lives``, which stays private — that is this soul's own + # rebirth history, not a link anyone else needs to follow. + "parent_did": self._identity.parent_did, + "generation": self._identity.generation, } @classmethod diff --git a/src/soul_protocol/runtime/types.py b/src/soul_protocol/runtime/types.py index 4e6861a5..82d7a2fd 100644 --- a/src/soul_protocol/runtime/types.py +++ b/src/soul_protocol/runtime/types.py @@ -1,4 +1,8 @@ # types.py — All Pydantic data models for the Digital Soul Protocol +# Updated: 2026-09-07 (terrarium) — Identity gains lineage: ``parent_did`` and +# ``generation``, written by Soul.fork(). Lineage (parent -> child) is a +# different axis from reincarnation (same soul, new life) — see the comment +# on the fields. Both default so pre-lineage souls load unchanged. # Updated: 2026-07-18 (#285) — MemoryEntry consolidated into spec/memory.py. # runtime/types.py re-exports it for backward compatibility. Removed # duplicate MemoryVisibility, MemoryCategory, MemoryProvenance definitions. @@ -137,6 +141,14 @@ class Identity(BaseModel): bond: Bond = Field(default_factory=Bond) incarnation: int = 1 previous_lives: list[str] = Field(default_factory=list) + # Lineage (parent -> child), written by Soul.fork(). This is a DIFFERENT + # axis from reincarnation: ``incarnation`` / ``previous_lives`` track the + # SAME soul living again, while ``parent_did`` / ``generation`` track a + # NEW soul descended from another. A soul can have both — a 3rd-generation + # child on its 2nd incarnation. Never conflate them. + # Both default so souls written before lineage existed load unchanged. + parent_did: str | None = None + generation: int = 1 def model_post_init(self, __context: Any) -> None: """Auto-migrate bonded_to to bonds if bonds is empty.""" diff --git a/tests/test_fork.py b/tests/test_fork.py new file mode 100644 index 00000000..db536074 --- /dev/null +++ b/tests/test_fork.py @@ -0,0 +1,358 @@ +# test_fork.py — Tests for Soul.fork() reproduction with lineage +# Created: 2026-09-07 (terrarium) — Lineage fields, OCEAN drift and clamping, +# tier inheritance (episodic never), immutable-trait guard, backward compat +# for pre-lineage souls, and the `soul fork` CLI end to end. + +from __future__ import annotations + +import json + +import pytest +from click.testing import CliRunner + +from soul_protocol.cli.main import cli +from soul_protocol.runtime.soul import OCEAN_TRAITS, Soul +from soul_protocol.runtime.types import MemoryType, SoulConfig + + +async def _drifting_parent(name: str = "Root", **kwargs) -> Soul: + """Birth a parent whose OCEAN is allowed to drift on fork. + + The default ``EvolutionConfig.immutable_traits`` contains ``"personality"``, + which freezes all five OCEAN traits. Tests that want to observe drift have + to opt out of that first. + """ + soul = await Soul.birth(name, **kwargs) + config = soul.serialize() + config.evolution.immutable_traits = ["core_values"] + return Soul(config) + + +# --------------------------------------------------------------------------- +# Lineage +# --------------------------------------------------------------------------- + + +async def test_fork_sets_lineage(): + """A child gets a new DID, the parent's DID, and generation + 1.""" + parent = await Soul.birth("Aria", archetype="The Founder") + child = await parent.fork("Vale") + + assert child.did != parent.did + assert child.did.startswith("did:soul:vale-") + assert child.identity.parent_did == parent.did + assert child.identity.generation == parent.identity.generation + 1 + assert parent.identity.generation == 1 + + +async def test_generation_stacks_across_forks(): + """Each fork advances the generation counter by one.""" + gen1 = await Soul.birth("Aria") + gen2 = await gen1.fork("Vale") + gen3 = await gen2.fork("Wren") + + assert [s.identity.generation for s in (gen1, gen2, gen3)] == [1, 2, 3] + assert gen3.identity.parent_did == gen2.did + assert gen2.identity.parent_did == gen1.did + assert gen1.identity.parent_did is None + + +async def test_fork_is_not_reincarnation(): + """Lineage and rebirth are separate axes and must not bleed into each other.""" + parent = await Soul.birth("Aria") + child = await parent.fork("Vale") + + assert child.identity.incarnation == 1 + assert child.identity.previous_lives == [] + + reborn = await Soul.reincarnate(parent) + assert reborn.identity.parent_did is None + assert reborn.identity.generation == 1 + + +async def test_fork_inherits_archetype_and_values(): + parent = await Soul.birth("Aria", archetype="The Founder", values=["water", "truth"]) + child = await parent.fork("Vale") + + assert child.archetype == "The Founder" + assert child.identity.core_values == ["water", "truth"] + # A copy, not a shared list. + child.identity.core_values.append("noise") + assert parent.identity.core_values == ["water", "truth"] + + +# --------------------------------------------------------------------------- +# OCEAN drift +# --------------------------------------------------------------------------- + + +async def test_drift_stays_within_width_and_bounds(): + """Every trait lands within +/- drift of the parent and inside 0..1.""" + parent = await _drifting_parent( + "Root", + ocean={ + "openness": 0.99, + "conscientiousness": 0.5, + "extraversion": 0.5, + "agreeableness": 0.5, + "neuroticism": 0.01, + }, + ) + p = parent.dna.personality + width = 0.5 + clamped = False + + for _ in range(200): + child = await parent.fork("Vale", drift=width) + c = child.dna.personality + for trait in OCEAN_TRAITS: + parent_value = getattr(p, trait) + child_value = getattr(c, trait) + assert 0.0 <= child_value <= 1.0 + lower = max(0.0, parent_value - width) + upper = min(1.0, parent_value + width) + assert lower <= child_value <= upper + if child_value in (0.0, 1.0): + clamped = True + + assert clamped, "expected at least one trait to hit a 0.0/1.0 clamp" + + +async def test_drift_actually_moves_traits(): + """A non-zero drift on a mutable soul does not produce a clone.""" + parent = await _drifting_parent("Root") + child = await parent.fork("Vale", drift=0.2) + + p, c = parent.dna.personality, child.dna.personality + assert any(getattr(p, t) != getattr(c, t) for t in OCEAN_TRAITS) + + +async def test_drift_zero_reproduces_parent_ocean(): + parent = await _drifting_parent("Root", ocean={"openness": 0.73, "neuroticism": 0.21}) + child = await parent.fork("Vale", drift=0) + + p, c = parent.dna.personality, child.dna.personality + assert all(getattr(p, t) == getattr(c, t) for t in OCEAN_TRAITS) + + +async def test_drift_defaults_to_mutation_rate(): + """The drift default is the parent's EvolutionConfig.mutation_rate.""" + parent = await _drifting_parent("Root") + config = parent.serialize() + config.evolution.mutation_rate = 0.0 + parent = Soul(config) + + child = await parent.fork("Vale") + p, c = parent.dna.personality, child.dna.personality + assert all(getattr(p, t) == getattr(c, t) for t in OCEAN_TRAITS) + + +async def test_immutable_traits_are_never_drifted(): + """The default config freezes 'personality', so OCEAN copies verbatim.""" + parent = await Soul.birth("Root", ocean={"openness": 0.42, "agreeableness": 0.66}) + assert "personality" in parent.serialize().evolution.immutable_traits + + child = await parent.fork("Vale", drift=0.9) + p, c = parent.dna.personality, child.dna.personality + assert all(getattr(p, t) == getattr(c, t) for t in OCEAN_TRAITS) + + +async def test_negative_drift_rejected(): + parent = await Soul.birth("Root") + with pytest.raises(ValueError, match="drift must be >= 0"): + await parent.fork("Vale", drift=-0.1) + + +# --------------------------------------------------------------------------- +# Memory inheritance +# --------------------------------------------------------------------------- + + +async def test_episodic_never_inherited_even_when_requested(): + """Episodic is dropped from inherit; procedural still passes down.""" + parent = await Soul.birth("Root") + await parent.remember("dug the well at dawn", type=MemoryType.EPISODIC) + await parent.remember("to find water, follow the reeds", type=MemoryType.PROCEDURAL) + + child = await parent.fork("Vale", inherit=["core", "procedural", "episodic"]) + + assert child.memory.episodic_entries() == [] + procedures = [e.content for e in child.memory.procedural_entries()] + assert procedures == ["to find water, follow the reeds"] + + +async def test_semantic_only_when_asked(): + parent = await Soul.birth("Root") + await parent.remember("the spring runs dry in summer", type=MemoryType.SEMANTIC) + + default_child = await parent.fork("Vale") + assert default_child.memory.semantic_facts() == [] + + asking_child = await parent.fork("Wren", inherit=["core", "procedural", "semantic"]) + assert len(asking_child.memory.semantic_facts()) == 1 + + +async def test_unknown_tier_rejected(): + parent = await Soul.birth("Root") + with pytest.raises(ValueError, match="Unknown memory tier"): + await parent.fork("Vale", inherit=["core", "dreams"]) + + +async def test_charter_lands_in_core_memory_attributed_to_parent(): + parent = await Soul.birth("Aria") + child = await parent.fork("Vale", charter="Keep the well open to everyone.") + + persona = child.get_core_memory().persona + assert "Keep the well open to everyone." in persona + assert "Aria" in persona + assert parent.did in persona + # The parent's own core memory is untouched. + assert "Keep the well open" not in parent.get_core_memory().persona + + +async def test_child_does_not_inherit_the_mutation_log(): + parent = await Soul.birth("Aria") + await parent.propose_evolution("communication.warmth", "high", "user prefers warmth") + assert parent.pending_mutations + + child = await parent.fork("Vale") + assert child.pending_mutations == [] + assert child.evolution_history == [] + + +# --------------------------------------------------------------------------- +# Persistence + backward compatibility +# --------------------------------------------------------------------------- + + +async def test_child_saves_and_round_trips(tmp_path): + """A forked child is a fully valid soul; lineage survives save + awaken.""" + parent = await Soul.birth("Aria") + child = await parent.fork("Vale", charter="Keep the well open.") + + target = tmp_path / "vale" + await child.save_local(target) + restored = await Soul.awaken(target) + + assert restored.identity.parent_did == parent.did + assert restored.identity.generation == 2 + assert "Keep the well open." in restored.get_core_memory().persona + + +async def test_exported_child_round_trips(tmp_path): + parent = await Soul.birth("Aria") + child = await parent.fork("Vale") + + target = tmp_path / "vale.soul" + await child.export(target, include_keys=True) + restored = await Soul.awaken(target) + + assert restored.identity.parent_did == parent.did + assert restored.identity.generation == 2 + + +async def test_pre_lineage_soul_still_loads(): + """A soul serialized before lineage existed loads with defaults.""" + soul = await Soul.birth("Legacy") + raw = soul.serialize().model_dump(mode="json") + raw["identity"].pop("parent_did") + raw["identity"].pop("generation") + + restored = SoulConfig.model_validate(raw) + + assert restored.identity.parent_did is None + assert restored.identity.generation == 1 + + +async def test_public_profile_carries_lineage(): + parent = await Soul.birth("Aria") + child = await parent.fork("Vale") + + assert parent.public_profile()["parent_did"] is None + assert parent.public_profile()["generation"] == 1 + assert child.public_profile()["parent_did"] == parent.did + assert child.public_profile()["generation"] == 2 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def test_fork_cli_end_to_end(tmp_path): + runner = CliRunner() + parent_path = str(tmp_path / "aria.soul") + child_path = str(tmp_path / "vale.soul") + + assert runner.invoke(cli, ["birth", "Aria", "-o", parent_path]).exit_code == 0 + + result = runner.invoke( + cli, + [ + "fork", + parent_path, + "--child", + "Vale", + "--charter", + "Keep the well open", + "-o", + child_path, + ], + ) + + assert result.exit_code == 0, result.output + assert "Forked" in result.output + assert "Vale" in result.output + + inspected = runner.invoke(cli, ["inspect", child_path]) + assert inspected.exit_code == 0 + assert "Generation 2" in inspected.output + + +def test_fork_cli_json_is_machine_readable(tmp_path): + runner = CliRunner() + parent_path = str(tmp_path / "aria.soul") + child_path = str(tmp_path / "vale.soul") + + runner.invoke(cli, ["birth", "Aria", "-o", parent_path]) + result = runner.invoke( + cli, + [ + "fork", + parent_path, + "--child", + "Vale", + "--drift", + "0.1", + "--inherit", + "core,procedural", + "-o", + child_path, + "--json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + + assert payload["child"] == "Vale" + assert payload["generation"] == 2 + assert payload["parent_did"].startswith("did:soul:aria-") + assert payload["drift"] == 0.1 + assert payload["inherited"] == ["core", "procedural"] + # Default config freezes personality, so the CLI must say so. + assert payload["frozen_traits"] == list(OCEAN_TRAITS) + assert set(payload["ocean"]) == set(OCEAN_TRAITS) + + +def test_fork_cli_rejects_unknown_tier(tmp_path): + runner = CliRunner() + parent_path = str(tmp_path / "aria.soul") + + runner.invoke(cli, ["birth", "Aria", "-o", parent_path]) + result = runner.invoke( + cli, ["fork", parent_path, "--child", "Vale", "--inherit", "core,dreams"] + ) + + assert result.exit_code == 1 + assert "Unknown memory tier" in result.output From 5444960c0d3702aba7fee38642fcb48171005f9a Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Mon, 7 Sep 2026 00:52:09 +0530 Subject: [PATCH 2/4] fix(runtime): reincarnate preserves fork lineage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reincarnate() built the new Identity without parent_did or generation, so a reborn child came back orphaned at generation 1. Lineage and rebirth are independent axes: a rebirth is a new life for the same soul, which is still its parent's child and still at the same depth in the family tree. Only the rebirth axis advances. Adds the case the fork tests missed — reincarnating the gen-1 parent made the assertions trivially true either way, so the drop went unnoticed. The new test forks, then reincarnates the child, and checks both axes. Also names origin_story and prime_directive in fork()'s does-not-inherit list; they were already excluded, just undocumented. --- CHANGELOG.md | 1 + src/soul_protocol/runtime/soul.py | 19 ++++++++++++++----- tests/test_fork.py | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39da4b6e..86e3d266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Memory inheritance is opt-in per tier** — `inherit` defaults to `("core", "procedural")`, because how-tos passing down is how culture spreads. `semantic` and `social` pass only when named. **Episodic memory is always empty on the child**, even when the caller asks for it: a child does not remember its parent's life. Bonds, `role`, the trust chain, the self-model and the knowledge graph stay with the parent. `role` in particular is deliberately dropped so a `root` governance soul cannot accidentally hand its undeletable status to a child. - **`EvolutionConfig.mutation_rate` finally has a reader.** The field has been serialized in every soul since the evolution system shipped and nothing consumed it; it is now the default drift width for `fork()`. - **Known consequence:** `EvolutionConfig.immutable_traits` defaults to `["personality", "core_values"]`, and `fork()` honours it, so a default-configured parent produces an **OCEAN clone** no matter what `drift` is set to. `soul fork` prints a warning and reports the frozen traits in `--json`; `soul_protocol.runtime.soul.frozen_ocean_traits()` exposes the same check. Drop `"personality"` from the parent's `immutable_traits` to let children diverge. +- **`Soul.reincarnate()` now carries lineage through rebirth.** It previously built the new `Identity` without `parent_did` / `generation`, so reborn souls came back orphaned at generation 1. Rebirth is a new life for the *same* soul — it is still its parent's child at the same depth in the family tree — so only the rebirth axis advances. Souls reincarnated before this release keep whatever their file already says; nothing is rewritten on load. - **New CLI: `soul fork --child `** with `--drift`, `--inherit`, `--charter`, `--output/-o` and `--json`. - **Lineage read path:** `Soul.public_profile()` now carries `parent_did` and `generation` (unlike `previous_lives`, which stays private — a parent DID is a link others walk). `soul inspect` and `soul status` show `Parent` / `Generation` lines for forked souls only, so output for unforked souls is unchanged. diff --git a/src/soul_protocol/runtime/soul.py b/src/soul_protocol/runtime/soul.py index 6a37154d..120d5e55 100644 --- a/src/soul_protocol/runtime/soul.py +++ b/src/soul_protocol/runtime/soul.py @@ -177,6 +177,8 @@ # distinct from reincarnate() which is the SAME soul living again. OCEAN # traits drift on inheritance; the drift default is the first real consumer # of EvolutionConfig.mutation_rate. public_profile() now carries lineage. +# reincarnate() carries parent_did / generation through unchanged so the two +# axes stay independent — a rebirth does not orphan a forked soul. # Updated: feat/soul-encryption — Re-raise SoulDecryptionError without wrapping # alongside SoulEncryptedError in awaken() exception handling. # Updated: feat/soul-encryption — Added password parameter to awaken() and export() @@ -1085,7 +1087,7 @@ async def reincarnate( new_name = name or old_soul.name old_identity = old_soul.identity - # Build lineage + # Build rebirth history previous_lives = list(old_identity.previous_lives) previous_lives.append(old_soul.did) @@ -1100,6 +1102,12 @@ async def reincarnate( bond=old_identity.bond.model_copy(), incarnation=old_identity.incarnation + 1, previous_lives=previous_lives, + # Fork lineage carries through rebirth untouched: rebirth is a new + # life for the SAME soul, so it is still its parent's child and + # still at the same depth in the family tree. Only the rebirth + # axis advances here. + parent_did=old_identity.parent_did, + generation=old_identity.generation, ) config = SoulConfig( @@ -1157,10 +1165,11 @@ async def fork( even when the caller asks for it. ``semantic`` and ``social`` are inherited only when named explicitly; ``core`` and ``procedural`` are the default, because how-tos passing down is how culture spreads. - - **Nothing else.** No bonds, no role, no trust chain, no mutation - history, no self-model, no knowledge graph — those belong to the - parent's life, and a ``role="root"`` child would inherit the - undeletable-governance-soul guard by accident. + - **Nothing else.** No bonds, no role, no origin story, no prime + directive, no trust chain, no mutation history, no self-model, no + knowledge graph — those belong to the parent's life, and a + ``role="root"`` child would inherit the undeletable-governance-soul + guard by accident. A child's own origin is its ``charter``. Args: child_name: Name for the child soul. diff --git a/tests/test_fork.py b/tests/test_fork.py index db536074..9e6f3e3a 100644 --- a/tests/test_fork.py +++ b/tests/test_fork.py @@ -70,6 +70,20 @@ async def test_fork_is_not_reincarnation(): assert reborn.identity.generation == 1 +async def test_a_child_can_be_reborn_without_losing_its_lineage(): + """Both axes advance independently — a gen-2 soul on its 2nd incarnation.""" + parent = await Soul.birth("Aria") + child = await parent.fork("Vale") + reborn = await Soul.reincarnate(child) + + # Rebirth axis moved. + assert reborn.identity.incarnation == 2 + assert child.did in reborn.identity.previous_lives + # Lineage axis did not — it is still Aria's child, still generation 2. + assert reborn.identity.parent_did == parent.did + assert reborn.identity.generation == 2 + + async def test_fork_inherits_archetype_and_values(): parent = await Soul.birth("Aria", archetype="The Founder", values=["water", "truth"]) child = await parent.fork("Vale") From ce130d45f915f5ef943fb69f9b180240d5cdf91f Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Mon, 7 Sep 2026 01:09:30 +0530 Subject: [PATCH 3/4] feat(runtime): birth() takes evolution config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soul.birth() had no way to set EvolutionConfig, so a caller who wanted a soul whose children could diverge had to reach into soul._evolution.config after the fact. That matters because immutable_traits defaults to ["personality", "core_values"], and the personality category gates all five OCEAN traits — so a default-born soul forks into an exact OCEAN clone however large a drift is asked for. birth(evolution=...) accepts an EvolutionConfig or a plain dict and wires it into SoulConfig. Passing immutable_traits without "personality" is now the supported way to let a lineage diverge. Three tests: a default-born parent forks to a clone (the trap, pinned so it cannot regress silently), a dict-configured parent produces a child that drifts within mutation_rate, and an EvolutionConfig object is accepted as-is. --- src/soul_protocol/runtime/soul.py | 21 ++++++++++++++ tests/test_fork.py | 46 ++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/soul_protocol/runtime/soul.py b/src/soul_protocol/runtime/soul.py index 120d5e55..a4147904 100644 --- a/src/soul_protocol/runtime/soul.py +++ b/src/soul_protocol/runtime/soul.py @@ -256,6 +256,7 @@ BondTarget, CommunicationStyle, CoreMemory, + EvolutionConfig, GeneralEvent, Identity, Interaction, @@ -910,6 +911,9 @@ async def birth( dspy_optimized_path: str | None = None, # F4 — Eternal storage eternal: EternalStorageManager | None = None, + # Evolution — settable at birth so a caller can decide up front what may + # change over a life, and what a child may inherit differently. + evolution: EvolutionConfig | dict[str, Any] | None = None, **kwargs, ) -> Soul: """Birth a new Soul. @@ -942,6 +946,11 @@ async def birth( Falls back silently to heuristic if dspy is not available. dspy_model: DSPy-compatible LM model string (default: claude-haiku-4-5). dspy_optimized_path: Path to pre-optimized DSPy module weights. + evolution: Evolution settings, as an EvolutionConfig or a dict, e.g. + ``{"immutable_traits": ["core_values"]}``. The default freezes the + whole "personality" category, which makes :meth:`fork` return an + OCEAN clone — pass immutable_traits without "personality" when + the soul's children should be able to diverge. **kwargs: Additional arguments reserved for future use. Ignored with a warning so typoed configuration arguments are visible without breaking forward compatibility. @@ -985,9 +994,21 @@ async def birth( biorhythms=dna_bio, ) + # Evolution config. Left at defaults this freezes the whole "personality" + # category, which means fork() produces an OCEAN clone — see fork()'s note. + # A caller that wants children to diverge passes immutable_traits without + # "personality" here, rather than reaching into soul internals afterwards. + if evolution is None: + evolution_config = EvolutionConfig() + elif isinstance(evolution, EvolutionConfig): + evolution_config = evolution + else: + evolution_config = EvolutionConfig(**evolution) + config = SoulConfig( identity=identity, dna=dna, + evolution=evolution_config, lifecycle=LifecycleState.ACTIVE, ) diff --git a/tests/test_fork.py b/tests/test_fork.py index 9e6f3e3a..da7eed3b 100644 --- a/tests/test_fork.py +++ b/tests/test_fork.py @@ -12,7 +12,7 @@ from soul_protocol.cli.main import cli from soul_protocol.runtime.soul import OCEAN_TRAITS, Soul -from soul_protocol.runtime.types import MemoryType, SoulConfig +from soul_protocol.runtime.types import EvolutionConfig, MemoryType, SoulConfig async def _drifting_parent(name: str = "Root", **kwargs) -> Soul: @@ -370,3 +370,47 @@ def test_fork_cli_rejects_unknown_tier(tmp_path): assert result.exit_code == 1 assert "Unknown memory tier" in result.output + + +# --------------------------------------------------------------------------- +# birth(evolution=...) — the supported way to let children diverge. +# Without it a caller has to reach into Soul internals, and the default config +# freezes the whole "personality" category, so fork() returns an OCEAN clone. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_birth_defaults_freeze_ocean_so_a_fork_is_a_clone(): + parent = await Soul.birth(name="DefaultParent", ocean={"openness": 0.6}) + assert parent._evolution.config.immutable_traits == ["personality", "core_values"] + + child = await parent.fork("DefaultChild", drift=0.5) + + assert child._dna.personality.openness == parent._dna.personality.openness + + +@pytest.mark.asyncio +async def test_birth_accepts_an_evolution_dict_and_children_then_diverge(): + parent = await Soul.birth( + name="OpenParent", + ocean={"openness": 0.6}, + evolution={"immutable_traits": ["core_values"], "mutation_rate": 0.08}, + ) + assert parent._evolution.config.immutable_traits == ["core_values"] + assert parent._evolution.config.mutation_rate == 0.08 + + child = await parent.fork("OpenChild") + + # drift defaults to mutation_rate, so the child sits within 0.08 of 0.6. + assert child._dna.personality.openness != parent._dna.personality.openness + assert abs(child._dna.personality.openness - 0.6) <= 0.08 + + +@pytest.mark.asyncio +async def test_birth_accepts_an_evolution_config_object(): + cfg = EvolutionConfig(immutable_traits=["core_values"], mutation_rate=0.2) + + parent = await Soul.birth(name="ObjParent", evolution=cfg) + + assert parent._evolution.config.immutable_traits == ["core_values"] + assert parent._evolution.config.mutation_rate == 0.2 From 919b02523525e81b4392b6483bfedb38f79bf881 Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Fri, 11 Sep 2026 20:02:42 +0530 Subject: [PATCH 4/4] feat(runtime): seedable OCEAN drift in fork(), and lineage naming fork() and _drift_ocean() take rng: random.Random | None; a seeded instance makes the child's OCEAN drift reproducible while the DID stays fresh. The CLI exposes it as soul fork --seed N. Header and banner comments that tagged this work by the downstream project now say (lineage), the protocol-side name. --- CHANGELOG.md | 4 +- docs/api-reference.md | 2 + docs/cli-reference.md | 1 + src/soul_protocol/cli/main.py | 14 ++++++- src/soul_protocol/runtime/soul.py | 18 ++++++-- src/soul_protocol/runtime/types.py | 2 +- tests/test_fork.py | 66 +++++++++++++++++++++++++++++- 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e3d266..fb561b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Reproduction — soul forking with lineage -- **`Soul.fork(child_name, *, drift, inherit, charter)`** — a soul can now produce a child soul. The child gets a fresh DID, `identity.parent_did` pointing at the parent, and `identity.generation = parent.generation + 1`. Its DNA is deep-copied and the five OCEAN traits each drift by an independent uniform delta in `[-drift, +drift]`, clamped to the schema's 0..1. Archetype, core values and the evolution *rules* carry over; the parent's mutation log does not. +- **`Soul.fork(child_name, *, drift, inherit, charter, rng)`** — a soul can now produce a child soul. The child gets a fresh DID, `identity.parent_did` pointing at the parent, and `identity.generation = parent.generation + 1`. Its DNA is deep-copied and the five OCEAN traits each drift by an independent uniform delta in `[-drift, +drift]`, clamped to the schema's 0..1. Archetype, core values and the evolution *rules* carry over; the parent's mutation log does not. Pass `rng=random.Random(seed)` to make the drift reproducible (same parent, same seed, same child traits; the DID is still fresh each call). - **`Identity.parent_did` and `Identity.generation`** — new fields, both defaulted, so souls written before this release load unchanged. Lineage (parent → child) is a separate axis from reincarnation (`incarnation` / `previous_lives`, the same soul living again); a soul can carry both. - **Memory inheritance is opt-in per tier** — `inherit` defaults to `("core", "procedural")`, because how-tos passing down is how culture spreads. `semantic` and `social` pass only when named. **Episodic memory is always empty on the child**, even when the caller asks for it: a child does not remember its parent's life. Bonds, `role`, the trust chain, the self-model and the knowledge graph stay with the parent. `role` in particular is deliberately dropped so a `root` governance soul cannot accidentally hand its undeletable status to a child. - **`EvolutionConfig.mutation_rate` finally has a reader.** The field has been serialized in every soul since the evolution system shipped and nothing consumed it; it is now the default drift width for `fork()`. - **Known consequence:** `EvolutionConfig.immutable_traits` defaults to `["personality", "core_values"]`, and `fork()` honours it, so a default-configured parent produces an **OCEAN clone** no matter what `drift` is set to. `soul fork` prints a warning and reports the frozen traits in `--json`; `soul_protocol.runtime.soul.frozen_ocean_traits()` exposes the same check. Drop `"personality"` from the parent's `immutable_traits` to let children diverge. - **`Soul.reincarnate()` now carries lineage through rebirth.** It previously built the new `Identity` without `parent_did` / `generation`, so reborn souls came back orphaned at generation 1. Rebirth is a new life for the *same* soul — it is still its parent's child at the same depth in the family tree — so only the rebirth axis advances. Souls reincarnated before this release keep whatever their file already says; nothing is rewritten on load. -- **New CLI: `soul fork --child `** with `--drift`, `--inherit`, `--charter`, `--output/-o` and `--json`. +- **New CLI: `soul fork --child `** with `--drift`, `--inherit`, `--charter`, `--seed`, `--output/-o` and `--json`. - **Lineage read path:** `Soul.public_profile()` now carries `parent_did` and `generation` (unlike `previous_lives`, which stays private — a parent DID is a link others walk). `soul inspect` and `soul status` show `Parent` / `Generation` lines for forked souls only, so output for unforked souls is unchanged. ### Architecture debt — extract health, rename list (#288) diff --git a/docs/api-reference.md b/docs/api-reference.md index 91918488..8a50f511 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -183,6 +183,7 @@ async def fork( drift: float | None = None, inherit: Sequence[str] = ("core", "procedural"), charter: str | None = None, + rng: random.Random | None = None, ) -> Soul ``` @@ -196,6 +197,7 @@ This is a different axis from `Soul.reincarnate()`. Reincarnation is the *same* | `drift` | `float \| None` | `None` | Half-width of the per-trait OCEAN mutation. Defaults to the parent's `EvolutionConfig.mutation_rate`. `0` copies OCEAN exactly. | | `inherit` | `Sequence[str]` | `("core", "procedural")` | Memory tiers to carry forward: `core`, `semantic`, `procedural`, `social` | | `charter` | `str \| None` | `None` | Charter written by the parent; stored in the child's core memory with parent attribution | +| `rng` | `random.Random \| None` | `None` | Generator used for every OCEAN drift draw. Pass a seeded `random.Random` for a reproducible child; the DID is still fresh each call. | **Returns:** `Soul` — ready to `save_local()` / `export()`. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4a88e65b..f465daf8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1274,6 +1274,7 @@ soul fork .soul/ --child Vale --charter "Keep the well open" --json | `--drift FLOAT` | OCEAN mutation half-width per trait. Defaults to the parent's `evolution.mutation_rate`. `0` copies OCEAN exactly. | | `--inherit TEXT` | Comma-separated memory tiers to inherit: `core`, `semantic`, `procedural`, `social`. Default `core,procedural`. | | `--charter TEXT` | Charter written by the parent; stored in the child's core memory with parent attribution. | +| `--seed INT` | Seed the OCEAN drift. The same parent and seed always produce the same child traits (the DID is still fresh). | | `--output, -o PATH` | Output path (default `./.soul`). An existing directory is saved into. | | `--json` | Emit machine-readable JSON instead of the rich panel. | diff --git a/src/soul_protocol/cli/main.py b/src/soul_protocol/cli/main.py index 7097541b..99959bb1 100644 --- a/src/soul_protocol/cli/main.py +++ b/src/soul_protocol/cli/main.py @@ -1,5 +1,7 @@ # cli/main.py — Click CLI for the Soul Protocol (org + user groups + runtime commands) -# Updated: 2026-09-07 (terrarium) — New `soul fork --child ` +# Updated: 2026-09-11 (lineage) — `soul fork --seed N` seeds the OCEAN drift +# so two runs from the same parent produce the same child traits. +# Updated: 2026-09-07 (lineage) — New `soul fork --child ` # command (reproduction with lineage): --drift, --inherit, --charter, # --output, --json. `soul inspect` and `soul status` now show Parent / # Generation for forked souls. @@ -132,6 +134,7 @@ import asyncio import builtins import json +import random import sys import warnings import zipfile @@ -3170,6 +3173,12 @@ async def _evolve(): "Episodic is never inherited.", ) @click.option("--charter", type=str, default=None, help="Charter written by the parent") +@click.option( + "--seed", + type=int, + default=None, + help="Seed the OCEAN drift so the same parent + seed always yields the same child", +) @click.option( "--output", "-o", @@ -3178,7 +3187,7 @@ async def _evolve(): help="Output path (default: ./.soul). An existing directory is saved into.", ) @click.option("--json", "as_json", is_flag=True, default=False, help="Emit JSON") -def fork_cmd(path, child_name, drift, inherit, charter, output, as_json): +def fork_cmd(path, child_name, drift, inherit, charter, seed, output, as_json): """Fork a child Soul from a parent — reproduction with lineage. The child gets a new DID, ``parent_did`` pointing at the parent, and @@ -3207,6 +3216,7 @@ async def _fork(): drift=drift, inherit=tiers, charter=charter, + rng=None if seed is None else random.Random(seed), ) except ValueError as exc: console.print(f"[red]Error:[/red] {exc}") diff --git a/src/soul_protocol/runtime/soul.py b/src/soul_protocol/runtime/soul.py index a4147904..dcec7923 100644 --- a/src/soul_protocol/runtime/soul.py +++ b/src/soul_protocol/runtime/soul.py @@ -172,7 +172,9 @@ # Previously, _dspy_processor was stored on Soul but never used during observe. # Updated: 2026-03-10 — Added forget(), forget_entity(), forget_before() for # GDPR-compliant memory deletion. Renamed old forget(memory_id) to forget_by_id(). -# Updated: 2026-09-07 (terrarium) — Added fork() for reproduction with lineage. +# Updated: 2026-09-11 (lineage) — fork() accepts ``rng: random.Random`` so a +# caller can make OCEAN drift reproducible; default draws stay unseeded. +# Updated: 2026-09-07 (lineage) — Added fork() for reproduction with lineage. # A fork is a NEW soul descended from this one (parent_did / generation), # distinct from reincarnate() which is the SAME soul living again. OCEAN # traits drift on inheritance; the drift default is the first real consumer @@ -302,7 +304,7 @@ # --------------------------------------------------------------------------- -# Reproduction (terrarium) — see Soul.fork() +# Reproduction (lineage) — see Soul.fork() # --------------------------------------------------------------------------- # The five OCEAN traits, in DNA order. Their dot-paths under DNA are # "personality.", so EvolutionConfig.immutable_traits gates them via @@ -340,12 +342,15 @@ def _drift_ocean( parent: Personality, width: float, immutable_traits: Sequence[str], + rng: random.Random | None = None, ) -> Personality: """Copy ``parent`` with each mutable OCEAN trait nudged and clamped. Each trait gets its own uniform delta in ``[-width, +width]``; the result is clamped to the schema's 0..1 range. Traits frozen by - ``immutable_traits`` are copied through untouched. + ``immutable_traits`` are copied through untouched. ``rng`` (a seeded + ``random.Random``) makes the draws reproducible; ``None`` uses the module + generator. """ child = parent.model_copy(deep=True) if width == 0: @@ -354,7 +359,7 @@ def _drift_ocean( for trait in OCEAN_TRAITS: if trait in frozen: continue - drifted = getattr(parent, trait) + random.uniform(-width, width) + drifted = getattr(parent, trait) + (rng or random).uniform(-width, width) setattr(child, trait, min(1.0, max(0.0, drifted))) return child @@ -1167,6 +1172,7 @@ async def fork( drift: float | None = None, inherit: Sequence[str] = ("core", "procedural"), charter: str | None = None, + rng: random.Random | None = None, ) -> Soul: """Fork a child soul from this one — reproduction with lineage. @@ -1202,6 +1208,9 @@ async def fork( accepted and ignored (see above); anything else raises. charter: Optional charter written *by this soul for the child*. Stored in the child's core memory, attributed to the parent. + rng: Optional ``random.Random`` used for every OCEAN drift draw. + Pass a seeded instance to get a reproducible child; ``None`` + (default) draws from the module-level generator. Returns: A fully valid child Soul, ready to ``save_local()`` / ``export()``. @@ -1238,6 +1247,7 @@ async def fork( self._dna.personality, drift_width, parent_evolution.immutable_traits, + rng, ) # role deliberately not inherited: a "root" org soul's child would diff --git a/src/soul_protocol/runtime/types.py b/src/soul_protocol/runtime/types.py index 82d7a2fd..9e13f748 100644 --- a/src/soul_protocol/runtime/types.py +++ b/src/soul_protocol/runtime/types.py @@ -1,5 +1,5 @@ # types.py — All Pydantic data models for the Digital Soul Protocol -# Updated: 2026-09-07 (terrarium) — Identity gains lineage: ``parent_did`` and +# Updated: 2026-09-07 (lineage) — Identity gains lineage: ``parent_did`` and # ``generation``, written by Soul.fork(). Lineage (parent -> child) is a # different axis from reincarnation (same soul, new life) — see the comment # on the fields. Both default so pre-lineage souls load unchanged. diff --git a/tests/test_fork.py b/tests/test_fork.py index da7eed3b..65749442 100644 --- a/tests/test_fork.py +++ b/tests/test_fork.py @@ -1,11 +1,13 @@ # test_fork.py — Tests for Soul.fork() reproduction with lineage -# Created: 2026-09-07 (terrarium) — Lineage fields, OCEAN drift and clamping, +# Updated: 2026-09-11 (lineage) — fork(rng=) reproducibility and `soul fork --seed`. +# Created: 2026-09-07 (lineage) — Lineage fields, OCEAN drift and clamping, # tier inheritance (episodic never), immutable-trait guard, backward compat # for pre-lineage souls, and the `soul fork` CLI end to end. from __future__ import annotations import json +import random import pytest from click.testing import CliRunner @@ -359,6 +361,68 @@ def test_fork_cli_json_is_machine_readable(tmp_path): assert set(payload["ocean"]) == set(OCEAN_TRAITS) +def _ocean(soul: Soul) -> dict[str, float]: + p = soul.dna.personality + return {t: getattr(p, t) for t in OCEAN_TRAITS} + + +async def test_same_seed_gives_identical_children(): + parent = await _drifting_parent() + a = await parent.fork("A", drift=0.1, rng=random.Random(7)) + b = await parent.fork("B", drift=0.1, rng=random.Random(7)) + assert _ocean(a) == _ocean(b) + assert _ocean(a) != _ocean(parent) + assert a.did != b.did # identity is never reproducible, only the drift + + +async def test_different_seeds_give_different_children(): + parent = await _drifting_parent() + a = await parent.fork("A", drift=0.1, rng=random.Random(7)) + b = await parent.fork("B", drift=0.1, rng=random.Random(8)) + assert _ocean(a) != _ocean(b) + + +async def test_fork_without_rng_still_drifts(): + parent = await _drifting_parent() + child = await parent.fork("Vale", drift=0.1) + assert _ocean(child) != _ocean(parent) + + +def test_fork_cli_seed_is_reproducible(tmp_path): + import asyncio + + parent_path = str(tmp_path / "aria.soul") + + async def _make_parent(): + soul = await Soul.birth( + "Aria", evolution={"immutable_traits": ["core_values"], "mutation_rate": 0.08} + ) + await soul.export(parent_path, include_keys=True) + + asyncio.run(_make_parent()) + + def _fork(seed: int, child: str) -> dict: + result = CliRunner().invoke( + cli, + [ + "fork", + parent_path, + "--child", + child, + "--seed", + str(seed), + "-o", + str(tmp_path / f"{child}.soul"), + "--json", + ], + ) + assert result.exit_code == 0, result.output + return json.loads(result.output)["ocean"] + + assert _fork(7, "a") == _fork(7, "b") + assert _fork(7, "c") != _fork(8, "d") + + def test_fork_cli_rejects_unknown_tier(tmp_path): runner = CliRunner() parent_path = str(tmp_path / "aria.soul")