File size: 6,510 Bytes
f0347b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
"""Pure-Python spec engine for personaxis.md personas (F1).

The living loop (F2) must never write state.json directly. Every mutation
goes through `mutate()` here: it reads the declared envelope (range) for a
field straight from <slug>/personaxis.md, clamps the requested delta to that
envelope, appends an audit-log entry, and writes <slug>/state.json. There is
no external CLI or subprocess involved - personaxis.md (the spec) and
state.json (the runtime values) are just YAML/JSON files this module reads
and writes directly.

Usage:
    from engine.spec_bridge import mutate, validate, get_state

    result = mutate("daimon", "mood.tone", 0.05, reason="user seemed pleased")
    state = get_state("daimon")
"""

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal

import yaml

REPO_ROOT = Path(__file__).resolve().parent.parent
PERSONAS_DIR = REPO_ROOT / ".personaxis" / "personas"

Actor = Literal[
    "actor-llm",
    "runtime-decay",
    "runtime-context",
    "human-operator",
    "judge-correction",
]

# field prefix -> path of dict keys to reach {mean, range} in personaxis.md
_ENVELOPE_PATH: dict[str, tuple[str, ...]] = {
    "traits": ("personality", "traits"),
    "affect": ("affect", "baseline", "core_affect"),
    "mood": ("affect", "baseline", "mood"),
}


class SpecBridgeError(RuntimeError):
    """Raised when a mutation targets a field with no declared envelope, or
    when personaxis.md / state.json cannot be found or parsed."""


def _persona_md_path(slug: str) -> Path:
    path = PERSONAS_DIR / slug / "personaxis.md"
    if not path.exists():
        raise SpecBridgeError(f"No personaxis.md for persona '{slug}' at {path}")
    return path


def _state_path(slug: str) -> Path:
    path = PERSONAS_DIR / slug / "state.json"
    if not path.exists():
        raise SpecBridgeError(f"No state.json for persona '{slug}' at {path}")
    return path


def _load_spec(slug: str) -> dict[str, Any]:
    text = _persona_md_path(slug).read_text(encoding="utf-8")
    _, frontmatter, _ = text.split("---", 2)
    return yaml.safe_load(frontmatter)


def _envelope(spec: dict[str, Any], field: str) -> tuple[float, float] | None:
    """Return the declared (lo, hi) range for `field` (e.g. "mood.tone"), or
    None if `field` has no envelope in personaxis.md (personality/affect
    layers only - identity, character, etc. are not reachable here)."""
    layer, _, name = field.partition(".")
    path = _ENVELOPE_PATH.get(layer)
    if path is None:
        return None
    node: Any = spec
    for key in path:
        node = node.get(key, {})
    spec_field = node.get(name)
    if not spec_field or "range" not in spec_field:
        return None
    lo, hi = spec_field["range"]
    return float(lo), float(hi)


def mutate(
    slug: str,
    field: str,
    delta: float,
    *,
    reason: str,
    actor: Actor = "actor-llm",
) -> dict[str, Any]:
    """Apply a clamped, audited mutation to <slug>/state.json.

    Returns {"field", "from", "to", "clamped", "blocked", "raw"}.
    Raises SpecBridgeError if `field` has no declared envelope in
    personaxis.md (structural rejection - e.g. identity.*, character.*).
    """
    spec = _load_spec(slug)
    envelope = _envelope(spec, field)
    if envelope is None:
        raise SpecBridgeError(
            f"No envelope declared for '{field}' in {slug}'s personaxis.md "
            "(only personality.traits.*, affect.baseline.core_affect.* and "
            "affect.baseline.mood.* are mutable) - mutation refused."
        )
    lo, hi = envelope

    state_path = _state_path(slug)
    state = json.loads(state_path.read_text(encoding="utf-8"))
    if field not in state["values"]:
        raise SpecBridgeError(f"'{field}' has a declared envelope but no entry in {slug}'s state.json values.")

    before = float(state["values"][field])
    requested = before + delta
    after = min(max(requested, lo), hi)
    clamped = abs(after - requested) > 1e-12

    state["values"][field] = after
    state["mutation_log"].append(
        {
            "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + f"{datetime.now(timezone.utc).microsecond // 1000:03d}Z",
            "field": field,
            "from": before,
            "to": after,
            "delta_requested": delta,
            "clamped": clamped,
            "reason": reason,
            "actor": actor,
            "governance_blocked": False,
        }
    )
    state_path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")

    raw = f"ok {field}: {before} -> {after}"
    if clamped:
        raw += f" (clamped to [{lo}, {hi}])"
    return {"field": field, "from": before, "to": after, "clamped": clamped, "blocked": False, "raw": raw}


def validate(slug: str) -> dict[str, Any]:
    """Check that every value in <slug>/state.json sits within its declared
    envelope. Returns {"ok", "status", "raw"}."""
    spec = _load_spec(slug)
    state = get_state(slug)
    violations = []
    for field, value in state["values"].items():
        envelope = _envelope(spec, field)
        if envelope is None:
            continue
        lo, hi = envelope
        if not (lo - 1e-9 <= value <= hi + 1e-9):
            violations.append(f"{field}={value} outside [{lo}, {hi}]")

    if violations:
        return {"ok": False, "status": "FAIL", "raw": "FAIL: " + "; ".join(violations)}
    return {"ok": True, "status": "PASS", "raw": "PASS: all values within declared envelopes"}


def get_state(slug: str) -> dict[str, Any]:
    """Read the current <slug>/state.json (values, mutation_log, etc.)."""
    return json.loads(_state_path(slug).read_text(encoding="utf-8"))


if __name__ == "__main__":
    import sys

    sys.stdout.reconfigure(encoding="utf-8")

    # Smoke test for Gate G1: a clamped mutation + audit log entry, end-to-end from Python.
    slug = "daimon"
    print(f"validate({slug}) ->", validate(slug))

    before = get_state(slug)["values"]["mood.tone"]
    print(f"mood.tone before: {before}")

    result = mutate(
        slug,
        "mood.tone",
        1.0,
        reason="smoke test: large positive delta should clamp to range max",
        actor="actor-llm",
    )
    print("mutate ->", result)

    after = get_state(slug)
    print(f"mood.tone after: {after['values']['mood.tone']}")
    print(f"mutation_log entries: {len(after['mutation_log'])}")
    print(f"last entry: {after['mutation_log'][-1]}")