File size: 7,102 Bytes
c8fbdf1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""CocoonSelfTrainer — teach the adaptive model from first-hand experience. SHADOW.



Jonathan's idea: "let the model train itself on real-world data it's seen

first-hand." Codette already stores that data — the cocoons. This feeds it to the

adaptive sentiment model, using each cocoon's ALREADY-MEASURED emotional signal as

a weak label.



The one hard-won rule this is built around: self-training on first-hand data is

exactly where the optimizer went wrong (it learned from its own benchmark harness).

So the guards here are the point, not an afterthought:



  1. LABELS COME ONLY FROM STORED EMOTIONAL SIGNALS, never from the sentiment

     model's own prediction. A model that labels its own training data drifts into

     a self-reinforcing loop. The label source must be independent of the learner.



  2. IT REFUSES DEGENERATE DATA. Too few examples, or one class dominating

     (e.g. all-positive cocoons), and it does NOT train — it reports why. Learning

     from bad data and reporting success would be the exact lie we refuse.



  3. SHADOW-ONLY. Trains a SEPARATE analyzer instance and logs what it learned. It

     does not touch any live model. Whether self-training ever runs live is a

     reviewed decision after reading the shadow log.

"""

from __future__ import annotations

import glob
import json
import time
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple

from reasoning_forge.sentiment_analyzer import SentimentAnalyzer


# Emotional classifications -> weak sentiment label. Only confident, clearly-
# valenced emotions are used; ambiguous ones are skipped (not guessed).
_POSITIVE_EMOTIONS = {
    "hope", "awe", "joy", "curiosity", "love", "gratitude", "excitement",
    "trust", "contentment", "pride", "relief", "admiration",
}
_NEGATIVE_EMOTIONS = {
    "fear", "anger", "sadness", "disgust", "frustration", "anxiety", "grief",
    "despair", "shame", "guilt", "contempt", "distress",
}


def cocoon_label(cocoon: dict) -> Optional[Tuple[str, int]]:
    """Extract (text, weak_label) from one cocoon, or None if unusable.



    Label source, in priority order — ALL are independently-measured signals,

    never the sentiment model's own output:

      1. emotional_valence (schema v3): >0.1 -> pos, <-0.1 -> neg, else skip

      2. emotional_classification (EMG cocoons): mapped via the emotion sets

    """
    if not isinstance(cocoon, dict):
        return None

    text = (cocoon.get("user_response_text") or cocoon.get("user_query")
            or cocoon.get("response_summary")
            or (cocoon.get("metadata") or {}).get("context") or "")
    text = str(text).strip()
    if not text:
        return None

    # 1) continuous valence
    val = cocoon.get("emotional_valence")
    if isinstance(val, (int, float)):
        if val > 0.1:
            return text, 1
        if val < -0.1:
            return text, 0
        return None  # near-neutral: skip, don't guess

    # 2) categorical emotion
    emo = str(cocoon.get("emotional_classification", "")).strip().lower()
    if emo in _POSITIVE_EMOTIONS:
        return text, 1
    if emo in _NEGATIVE_EMOTIONS:
        return text, 0
    return None


@dataclass
class SelfTrainReport:
    collected: int
    positive: int
    negative: int
    trained: bool
    reason: str
    ts: float = field(default_factory=time.time)

    def to_dict(self) -> dict:
        return asdict(self)


class CocoonSelfTrainer:
    """Shadow self-trainer with drift guards."""

    def __init__(self, min_examples: int = 20, min_minority_frac: float = 0.15):
        self.min_examples = min_examples
        self.min_minority_frac = min_minority_frac

    def collect_from_records(self, cocoons: Iterable[dict]) -> Tuple[List[str], List[int]]:
        texts, labels = [], []
        for c in cocoons:
            got = cocoon_label(c)
            if got:
                texts.append(got[0])
                labels.append(got[1])
        return texts, labels

    def collect_from_dir(self, cocoon_dir: str | Path = "cocoons") -> Tuple[List[str], List[int]]:
        recs = []
        for pat in ("*.cocoon", "*.json"):
            for f in glob.glob(str(Path(cocoon_dir) / "**" / pat), recursive=True):
                if "backup" in f.lower():
                    continue
                try:
                    recs.append(json.load(open(f, encoding="utf-8")))
                except Exception:
                    pass
        return self.collect_from_records(recs)

    def _guard(self, labels: List[int]) -> Tuple[bool, str]:
        """Refuse degenerate data. Returns (ok, reason)."""
        n = len(labels)
        if n < self.min_examples:
            return False, f"too few labelled examples ({n} < {self.min_examples})"
        pos = sum(labels)
        neg = n - pos
        minority = min(pos, neg)
        if minority == 0:
            return False, f"single-class data (pos={pos}, neg={neg}) — training would be degenerate"
        if minority / n < self.min_minority_frac:
            return False, (f"class imbalance too severe (minority {minority}/{n} = "
                           f"{minority/n:.2f} < {self.min_minority_frac}) — refusing to train")
        return True, "class balance and volume acceptable"

    def train_shadow(self, cocoons: Optional[Iterable[dict]] = None,

                     cocoon_dir: str | Path = "cocoons") -> Tuple[SelfTrainReport, Optional[SentimentAnalyzer]]:
        """Collect first-hand data, guard it, and (only if healthy) train a SHADOW

        analyzer. Returns (report, shadow_analyzer_or_None). Trains nothing live."""
        if cocoons is not None:
            texts, labels = self.collect_from_records(cocoons)
        else:
            texts, labels = self.collect_from_dir(cocoon_dir)

        pos = sum(labels)
        neg = len(labels) - pos
        ok, reason = self._guard(labels)
        if not ok:
            return SelfTrainReport(len(labels), pos, neg, trained=False, reason=reason), None

        shadow = SentimentAnalyzer(enable_adaptive=True)
        shadow.update(texts, labels)
        return SelfTrainReport(len(labels), pos, neg, trained=True,
                               reason="trained shadow model on first-hand cocoon data"), shadow

    def observe(self, report: SelfTrainReport, path: str | Path = None) -> None:
        """Append the report to the self-train shadow log (applied: false)."""
        path = Path(path) if path else Path(__file__).resolve().parent.parent / "data" / "self_train_shadow.jsonl"
        rec = report.to_dict()
        rec["mode"] = "shadow"
        rec["applied"] = False
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            with path.open("a", encoding="utf-8") as f:
                f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        except Exception:
            pass