File size: 11,420 Bytes
f592f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Always-on, layer-local pre-FFN + residual activation steering; frozen Qwen."""
import os,json,math,time,threading
from pathlib import Path
os.environ.setdefault('PYTORCH_ENABLE_MPS_FALLBACK','1')
# Bundled weights are read-only; auxiliary model-library caches live outside the signed app.
_bundled=Path(__file__).resolve().parent/'.hf/hub/models--Qwen--Qwen3.5-0.8B/snapshots/2fc06364715b967f1860aea9cf38778875588b17'
if _bundled.is_dir():
    os.environ.setdefault('AFFECTOR_MODEL',str(_bundled))
    os.environ['HF_HOME']=str(Path.home()/'Library/Caches/AFFECTOR/huggingface')

import torch
MODEL='Qwen/Qwen3.5-0.8B'
REVISION='2fc06364715b967f1860aea9cf38778875588b17'
from rewrite_policy import SYSTEM
from generation import repetition,sentence_end
# Matched contrastive examples; exploratory calibration, not validated emotion labels.
PAIRS=[
 [('I returned to the house with despair. Nothing felt worth keeping.','I returned to the house with joy. Everything felt worth keeping.'),('She read the letter with dread and bitterness.','She read the letter with delight and gratitude.'),('The morning felt bleak, hostile and hopeless.','The morning felt welcoming, hopeful and beautiful.'),('He regarded the meeting as a painful disappointment.','He regarded the meeting as a wonderful success.')],
 [('I felt ashamed of myself and dwelt on my own failures.','I felt angry at the people who had betrayed my trust.'),('Her sadness turned inward as she questioned herself.','Her sadness reached outward toward the suffering family.'),('He was proud of his own courage.','He admired the courage of his companions.'),('I worried about my own thoughts and feelings.','I worried about the safety of everyone outside.')],
 [('I felt mildly uneasy as I waited at the door.','I was overwhelmed by terror as I waited at the door.'),('She was slightly pleased with the news.','She was ecstatic, swept away by the news.'),('He felt a small twinge of sadness.','He was consumed by a devastating grief.'),('I felt faint irritation at the interruption.','I felt a blazing fury at the interruption.')]
]

class ActivationModule(torch.nn.Module):
    def __init__(self,basis):
        super().__init__();self.weight=torch.nn.Parameter(torch.tensor(basis,dtype=torch.float32),requires_grad=False)
        self.previous=None
    def reset(self):self.previous=None
    def forward(self,h,coordinates,controls,layer_weight):
        if controls['strength']==0 or controls['budget']==0 or layer_weight==0:return torch.zeros_like(h[:,-1,:])
        coeff=torch.tensor(coordinates,dtype=h.dtype,device=h.device)
        magnitude=coeff.abs().pow(1/controls['axis_temperature'])
        order=torch.argsort(magnitude,descending=True);mask=torch.zeros_like(coeff)
        running=0.;total=float(magnitude.sum())
        for index in order[:controls['axis_top_k']]:
            if running>=controls['axis_mass']*total and running>0:break
            mask[index]=1;running+=float(magnitude[index])
        coeff=coeff.sign()*magnitude*mask
        delta=coeff @ self.weight.to(device=h.device,dtype=h.dtype)
        rms=h[:,-1,:].float().pow(2).mean(-1,keepdim=True).sqrt().to(h.dtype)
        delta=delta.unsqueeze(0)*rms*controls['strength']*layer_weight
        if self.previous is not None:delta=controls['inertia']*self.previous+(1-controls['inertia'])*delta
        norm=delta.float().pow(2).mean(-1,keepdim=True).sqrt().clamp_min(1e-12)
        delta=delta*torch.clamp(controls['budget']*rms/norm,max=1)
        self.previous=delta.detach()
        return delta

class Engine:
    def __init__(self,progress=lambda s:None):
        from transformers import AutoTokenizer,Qwen3_5ForConditionalGeneration
        import transformers
        torch.set_num_threads(4)
        self.progress=progress
        self.device='mps' if torch.backends.mps.is_available() else 'cpu'
        progress('Loading frozen Qwen3.5-0.8B on '+self.device.upper()+'…')
        path=os.environ.get('AFFECTOR_MODEL')
        kwargs={'local_files_only':True}
        if not path:kwargs['revision']=REVISION;path=MODEL
        self.tokenizer=AutoTokenizer.from_pretrained(path,**kwargs)
        self.model=Qwen3_5ForConditionalGeneration.from_pretrained(path,dtype=torch.float32,attn_implementation='eager',**kwargs).to(self.device).eval()
        self.model.requires_grad_(False);self.layers=self.model.model.language_model.layers
        self.identity={'model':MODEL,'revision':REVISION,'width':1024,'site':'pre_ffn_with_residual','dtype':'float32','transformers':transformers.__version__,'torch':torch.__version__}
        self.injections=0;self.delta_rms=[]
    def tokens(self,system,text):
        rendered=self.tokenizer.apply_chat_template([{'role':'system','content':system},{'role':'user','content':text}],tokenize=False,add_generation_prompt=True,enable_thinking=False)
        inputs=self.tokenizer(rendered,return_tensors='pt').to(self.device)
        if inputs.input_ids.shape[-1]>8192:raise ValueError('Passage/prompt exceeds 8,192 tokens; shorten the passage or context.')
        return inputs
    @torch.inference_mode()
    def compile(self,g,cancel,progress):
        selected=sorted(map(int,g['layers']));captures={};handles=[]
        def capture(index):
            def hook(module,args):captures[index]=args[0][0,-1].detach().float().cpu().clone()
            return hook
        try:
            for index in selected:handles.append(self.layers[index].post_attention_layernorm.register_forward_pre_hook(capture(index)))
            vectors={str(i):[] for i in selected};step=0
            for pairs in PAIRS:
                differences={i:[] for i in selected}
                for negative,positive in pairs:
                    states=[]
                    for text in (negative,positive):
                        if cancel.is_set():raise InterruptedError('Calibration stopped.')
                        self.model(**self.tokens('Continue the passage in the same emotional voice.',text),use_cache=False,logits_to_keep=1)
                        states.append({i:captures[i].clone() for i in selected});step+=1;progress(f'Calibrating activation basis {step}/24…')
                    for i in selected:differences[i].append(states[1][i]-states[0][i])
                for i in selected:
                    vector=torch.stack(differences[i]).mean(0);vector/=vector.pow(2).mean().sqrt().clamp_min(1e-8)
                    vectors[str(i)].append(vector.tolist())
            return {'identity':self.identity,'vectors':vectors,'calibration':'Mean matched-context activation differences; 4 pairs per axis. Unvalidated English prototype.','pairs':PAIRS}
        finally:
            for handle in handles:handle.remove()
    def hooks(self,g,coordinates):
        if g['basis']['identity']!=self.identity:raise ValueError('Steering basis belongs to a different model/runtime. Recalibrate this geometry.')
        handles=[];pending={};modules={}
        if any(k not in g['basis']['vectors'] for k in g['layers']):raise ValueError('New layers need calibration before use.')
        for key,weight in g['layers'].items():
            if key not in g['basis']['vectors']:raise ValueError('New layers need calibration before use.')
            modules[key]=ActivationModule(g['basis']['vectors'][key]);layer=self.layers[int(key)]
            def before(module,args,key=key,weight=weight):
                h=args[0];d=modules[key](h,coordinates,g['controls'],weight);pending[key]=d
                if not torch.count_nonzero(d):return None
                self.injections+=1;self.delta_rms.append(float(d.float().pow(2).mean().sqrt()))
                y=h.clone();y[:,-1,:]+=d;return (y,*args[1:])
            def after(module,args,out,key=key):
                d=pending.pop(key,None)
                if d is None or not torch.count_nonzero(d):return out
                if not isinstance(out,torch.Tensor):raise RuntimeError('Unexpected decoder output; hook requires revalidation.')
                y=out.clone();y[:,-1,:]+=d;return y
            handles.extend([layer.post_attention_layernorm.register_forward_pre_hook(before),layer.register_forward_hook(after)])
        return handles
    @torch.inference_mode()
    def generate(self,text,*,g=None,coordinates=None,system=SYSTEM,max_tokens=512,temperature=0.,top_p=.95,top_k=40,cancel=None,time_limit=120,max_chars=None,soft_chars=None):
        from transformers import StoppingCriteria,StoppingCriteriaList
        cancel=cancel or threading.Event()
        start=time.monotonic();progress=self.progress;steps=[0];length_stopped=[False];quality_stop=[None]
        tokenizer=self.tokenizer;prompt_length=[0]
        class Stop(StoppingCriteria):
            def __call__(self,input_ids,scores,**kwargs):
                steps[0]+=1
                if steps[0]%64==0:progress(f'Local model · {steps[0]} output tokens · {int(time.monotonic()-start)} seconds')
                if max_chars is not None:
                    draft=tokenizer.decode(input_ids[0,prompt_length[0]:].tolist(),skip_special_tokens=True)
                    length_stopped[0]=len(draft)>=max_chars
                    if soft_chars is not None:
                        if steps[0]%8==0 and repetition(draft):quality_stop[0]='repetition'
                        elif len(draft)>=soft_chars and sentence_end(draft):quality_stop[0]='sentence_boundary'
                return cancel.is_set() or time.monotonic()-start>time_limit or length_stopped[0] or quality_stop[0] is not None
        inputs=self.tokens(system,text);prompt_length[0]=inputs.input_ids.shape[-1];handles=[];self.injections=0;self.delta_rms=[];start=time.monotonic()
        try:
            if g is not None:handles=self.hooks(g,coordinates)
            options={'do_sample':temperature>0,'max_new_tokens':int(max_tokens),'use_cache':True,'repetition_penalty':1.1,'no_repeat_ngram_size':8,'logits_to_keep':1,'pad_token_id':self.tokenizer.eos_token_id,'stopping_criteria':StoppingCriteriaList([Stop()])}
            if temperature>0:options.update(temperature=temperature,top_p=top_p,top_k=int(top_k))
            out=self.model.generate(**inputs,**options)
            if cancel.is_set() and max_chars is None:raise InterruptedError('Transformation stopped.')
            if time.monotonic()-start>time_limit and max_chars is None:raise ValueError('Local generation exceeded its 120-second limit; this passage remains unmodified or unscored.')
            ids=out[0,inputs.input_ids.shape[-1]:].tolist();eos=self.model.generation_config.eos_token_id;eos=eos if isinstance(eos,list) else [eos]
            decoded=self.tokenizer.decode(ids,skip_special_tokens=True).strip()
            reason='cancelled' if cancel.is_set() else quality_stop[0] if quality_stop[0] else 'character_limit' if length_stopped[0] else 'time_limit' if time.monotonic()-start>time_limit else 'token_limit' if ids and ids[-1] not in eos and len(ids)>=max_tokens else 'completed'
            return {'text':decoded[:max_chars] if max_chars is not None else decoded,'stop_reason':reason,'character_limit':max_chars,'tokens':len(ids),'truncated':bool(ids and ids[-1] not in eos and len(ids)>=max_tokens),'seconds':round(time.monotonic()-start,3),'decoding_guard':{'repetition_penalty':1.1,'no_repeat_ngram_size':8},'activation_injections':self.injections,'mean_delta_rms':sum(self.delta_rms)/max(1,len(self.delta_rms))}
        finally:
            for h in handles:h.remove()