Skip to content

Commit 7039107

Browse files
committed
trace ace: add V85 EvidenceEvent knowledge-state separator
1 parent c92ac94 commit 7039107

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python3
2+
"""V85: RGRS EvidenceEvent -> knowledge-state separator.
3+
4+
Compares three objective-cold arms:
5+
A0: V75 whole-session representation baseline.
6+
A1: explicit objective-conditioned EvidenceEvent IR with assistance/independence tags.
7+
A2: same EvidenceEvent IR with assistance/independence tags ablated.
8+
9+
Primary decision: A1 must beat A0 by >=0.003 log loss and materially beat A2 to count
10+
as a representation-level breakthrough. Otherwise retain as negative/conditional law.
11+
"""
12+
from __future__ import annotations
13+
import argparse, json, re
14+
from pathlib import Path
15+
import numpy as np
16+
import pandas as pd
17+
from scipy.sparse import csr_matrix, hstack
18+
from sklearn.feature_extraction.text import HashingVectorizer
19+
from sklearn.linear_model import LogisticRegression
20+
from sklearn.metrics import log_loss, roc_auc_score
21+
from sklearn.model_selection import GroupKFold
22+
23+
from v71_mastery_events import load_transcript, tokens, jaccard, char_ngram_overlap
24+
from v75_canonical_trajectory import load_training, trajectory_views, SEED
25+
from v81_target_segment_phase import choose_target_segment, phase_for
26+
27+
QUESTION_RE=re.compile(r"\?|\b(?:what|how|why|which|calculate|solve|find|show|explain|tell me|your turn|try|have a go)\b",re.I)
28+
POS_RE=re.compile(r"\b(?:correct|right|yes|exactly|great|brilliant|well done|good job|nice|perfect)\b",re.I)
29+
NEG_RE=re.compile(r"\b(?:not quite|incorrect|wrong|try again|check|remember|almost|no[, .])\b",re.I)
30+
HINT_RE=re.compile(r"\b(?:remember|think about|hint|try|look at|first|start by|let's|lets|together|we can|I'll|i will|let me)\b",re.I)
31+
SUPPLY_RE=re.compile(r"\b(?:the answer is|it is|equals|so .* is|that gives|we get|you should)\b",re.I)
32+
ACK_RE=re.compile(r"^(?:ok(?:ay)?|yes|yeah|yep|no|nope|thanks?|thank you|got it|sure|right|mhm|uh huh|great|cool)[.! ]*$",re.I)
33+
34+
PHASE_WEIGHT={'OTHER':.45,'GOAL':.25,'PRIOR':.35,'GUIDED':.55,'INDEPENDENT':1.0,'APPLICATION':1.1}
35+
36+
37+
def rel(text,obj):
38+
return float(max(jaccard(tokens(str(text)),tokens(str(obj))),.5*char_ngram_overlap(str(text),str(obj))))
39+
40+
41+
def substantive(s):
42+
s=str(s).strip()
43+
if not s or ACK_RE.match(s): return False
44+
# preserve short numeric/math answers
45+
if re.search(r"\d|[=+\-*/%]",s): return True
46+
return len(tokens(s))>=2
47+
48+
49+
def phase_replay(df):
50+
ph='OTHER'; out=[]
51+
for t in df.content.fillna('').astype(str):
52+
ph=phase_for(t,ph); out.append(ph)
53+
return out
54+
55+
56+
def evidence_events(df,obj):
57+
d=df.reset_index(drop=True).copy(); phases=phase_replay(d)
58+
events=[]
59+
n=len(d)
60+
for i,row in d.iterrows():
61+
if str(row.role).lower()!='student' or not substantive(row.content): continue
62+
# nearest preceding tutor question/prompt within 4 turns
63+
qidx=None
64+
for j in range(i-1,max(-1,i-5),-1):
65+
if str(d.iloc[j].role).lower()=='tutor' and QUESTION_RE.search(str(d.iloc[j].content)):
66+
qidx=j; break
67+
if qidx is None: continue
68+
q=str(d.iloc[qidx].content); ans=str(row.content)
69+
# immediate/near tutor feedback after response
70+
feedback=''; fbidx=None
71+
for j in range(i+1,min(n,i+4)):
72+
if str(d.iloc[j].role).lower()=='tutor': feedback=str(d.iloc[j].content); fbidx=j; break
73+
rq=rel(q,obj); ra=rel(ans,obj); relevance=max(rq,.4*ra)
74+
# Assistance is derived only from tutor turns after previous student response and before this answer.
75+
window=' '.join(str(d.iloc[j].content) for j in range(max(0,qidx-2),i) if str(d.iloc[j].role).lower()=='tutor')
76+
supplied=bool(SUPPLY_RE.search(window)); hinted=bool(HINT_RE.search(window))
77+
assistance=1.0 if supplied else (.6 if hinted else 0.0)
78+
independent=(phases[i] in ('INDEPENDENT','APPLICATION') and assistance<.3)
79+
pos=bool(POS_RE.search(feedback)); neg=bool(NEG_RE.search(feedback))
80+
# canonical state; tutor feedback is auxiliary, not ground truth
81+
if neg: state='UNRESOLVED_ERROR'
82+
elif pos and assistance>=.6: state='CORRECT_AFTER_GUIDANCE'
83+
elif pos and independent: state='INDEPENDENT_CORRECT'
84+
elif pos: state='SUPPORTED_CORRECT'
85+
else: state='UNJUDGED_RESPONSE'
86+
events.append({'i':i,'phase':phases[i],'q':q,'a':ans,'feedback':feedback,'rel':relevance,
87+
'assistance':assistance,'independent':independent,'state':state,
88+
'position':i/max(1,n-1),'pos':pos,'neg':neg})
89+
return events
90+
91+
92+
def render(events,obj,ablate=False):
93+
keep=sorted(events,key=lambda e:(e['rel']*(.4+.6*e['position'])*PHASE_WEIGHT.get(e['phase'],.4)),reverse=True)[:16]
94+
keep=sorted(keep,key=lambda e:e['i'])
95+
rows=[]
96+
for e in keep:
97+
tags=[f"PHASE={e['phase']}",f"STATE={e['state']}",f"REL={e['rel']:.2f}",f"POS={int(e['pos'])}",f"NEG={int(e['neg'])}"]
98+
if not ablate: tags += [f"ASSIST={e['assistance']:.1f}",f"INDEP={int(e['independent'])}"]
99+
rows.append('['+' '.join(tags)+'] [Q] '+e['q']+' [STUDENT] '+e['a'])
100+
return f"[OBJECTIVE] {obj}\n"+'\n'.join(rows)
101+
102+
103+
def nums(events,ablate=False):
104+
if not events: return np.zeros(22 if not ablate else 16,float)
105+
E=events; rels=np.array([e['rel'] for e in E]); pos=np.array([e['pos'] for e in E],float); neg=np.array([e['neg'] for e in E],float)
106+
ind=np.array([e['independent'] for e in E],float); ass=np.array([e['assistance'] for e in E],float); positions=np.array([e['position'] for e in E])
107+
app=np.array([e['phase']=='APPLICATION' for e in E],float); late=positions>=.6
108+
base=[len(E),rels.mean(),rels.max(),pos.mean(),neg.mean(),positions[pos>0].max() if pos.any() else 0,
109+
positions[neg>0].max() if neg.any() else 0,pos[late].mean() if late.any() else 0,neg[late].mean() if late.any() else 0,
110+
app.mean(),(pos*rels).sum()/(rels.sum()+1e-6),(neg*rels).sum()/(rels.sum()+1e-6),
111+
float(any(e['state']=='UNRESOLVED_ERROR' for e in E[-3:])),float(any(e['state']=='INDEPENDENT_CORRECT' for e in E[-3:])),
112+
sum(e['state']=='INDEPENDENT_CORRECT' for e in E),sum(e['state']=='CORRECT_AFTER_GUIDANCE' for e in E)]
113+
if ablate: return np.asarray(base,float)
114+
extra=[ass.mean(),ass[-3:].mean() if len(ass)>=3 else ass.mean(),ind.mean(),ind[late].mean() if late.any() else 0,
115+
(pos*ind*rels).sum()/(rels.sum()+1e-6),(neg*(1-ass)*rels).sum()/(rels.sum()+1e-6)]
116+
return np.asarray(base+extra,float)
117+
118+
119+
def build_sparse(texts,Z,prefix):
120+
hv=HashingVectorizer(n_features=2**18,alternate_sign=False,norm='l2',ngram_range=(1,2),lowercase=True)
121+
X=hv.transform([f'[{prefix}] '+x for x in texts]); Z=np.vstack(Z); Z=(Z-Z.mean(0))/(Z.std(0)+1e-6)
122+
return hstack([X,csr_matrix(Z)],format='csr')
123+
124+
125+
def build_v75(frame,transcripts):
126+
rows=[]; ns=[]
127+
for _,r in frame.iterrows():
128+
v,n,_=trajectory_views(transcripts[str(r.session_id)],str(r.learning_objective)); rows.append(v); ns.append(n)
129+
hv=HashingVectorizer(n_features=2**18,alternate_sign=False,norm='l2',ngram_range=(1,2),lowercase=True)
130+
parts=[hv.transform([f'[OBJECTIVE] {x}' for x in frame.learning_objective])]
131+
for k in rows[0].keys(): parts.append(hv.transform([f'[{k.upper()}] '+r[k] for r in rows]))
132+
Z=np.vstack(ns); Z=(Z-Z.mean(0))/(Z.std(0)+1e-6); parts.append(csr_matrix(Z))
133+
return hstack(parts,format='csr')
134+
135+
136+
def oof(X,y,splits,name):
137+
p=np.zeros(len(y)); fs=[]
138+
for k,(tr,va) in enumerate(splits,1):
139+
m=LogisticRegression(C=.25,max_iter=300,solver='liblinear',random_state=SEED).fit(X[tr],y[tr])
140+
q=np.clip(m.predict_proba(X[va])[:,1],1e-5,1-1e-5); p[va]=q
141+
row={'fold':k,'logloss':float(log_loss(y[va],q)),'auc':float(roc_auc_score(y[va],q))}; print(name,row); fs.append(row)
142+
return p,fs
143+
144+
145+
def run(a):
146+
f=load_training(a.features,a.labels).reset_index(drop=True)
147+
if a.limit: f=f.iloc[:a.limit].copy().reset_index(drop=True)
148+
cache={}
149+
for sid in f.session_id.astype(str).unique(): cache[sid]=load_transcript(a.transcripts/f'{sid}.csv')
150+
full_text=[]; abl_text=[]; full_num=[]; abl_num=[]; meta=[]
151+
for i,r in f.iterrows():
152+
d=cache[str(r.session_id)]; seg,m=choose_target_segment(d,str(r.learning_objective)); ev=evidence_events(seg,str(r.learning_objective))
153+
full_text.append(render(ev,str(r.learning_objective),False)); abl_text.append(render(ev,str(r.learning_objective),True))
154+
full_num.append(nums(ev,False)); abl_num.append(nums(ev,True)); meta.append({'events':len(ev),**m})
155+
if (i+1)%2500==0: print('rows',i+1)
156+
y=f.target.to_numpy(int); groups=(f.learning_objective_id if 'learning_objective_id' in f else f.learning_objective).astype(str).to_numpy()
157+
sp=list(GroupKFold(5).split(np.zeros(len(y)),y,groups))
158+
X0=build_v75(f,cache); X1=build_sparse(full_text,full_num,'EVIDENCE'); X2=build_sparse(abl_text,abl_num,'EVIDENCE_ABL')
159+
p0,f0=oof(X0,y,sp,'A0_v75'); p1,f1=oof(X1,y,sp,'A1_evidence'); p2,f2=oof(X2,y,sp,'A2_ablation')
160+
ll0=float(log_loss(y,p0)); ll1=float(log_loss(y,p1)); ll2=float(log_loss(y,p2))
161+
# Also test whether evidence is orthogonal to V75; fixed transparent grid only.
162+
blends=[]; best=None
163+
for w in np.linspace(0,1,21):
164+
q=np.clip((1-w)*p0+w*p1,1e-5,1-1e-5); ll=float(log_loss(y,q)); row={'evidence_weight':float(w),'logloss':ll}; blends.append(row)
165+
if best is None or ll<best['logloss']: best=row
166+
gain=ll0-ll1; causal=ll2-ll1
167+
if gain>=.003 and causal>=.001: decision='REPRESENTATION_BREAKTHROUGH'
168+
elif gain>=.001 and causal>0: decision='PROMISING_PARTIAL'
169+
elif best['logloss']<=ll0-.001: decision='ORTHOGONAL_SIGNAL_ONLY'
170+
else: decision='REJECT_OR_REFINE_R5'
171+
out={'primary':'objective-cold','A0_v75':ll0,'A1_evidence':ll1,'A2_ablation':ll2,'gain_vs_A0':gain,'causal_assistance_gain':causal,
172+
'best_blend':best,'decision':decision,'folds':{'A0':f0,'A1':f1,'A2':f2},
173+
'event_stats':{'mean_events':float(np.mean([m['events'] for m in meta])),'zero_event_fraction':float(np.mean([m['events']==0 for m in meta]))}}
174+
Path(a.out).write_text(json.dumps(out,indent=2)); print(json.dumps(out,indent=2))
175+
176+
if __name__=='__main__':
177+
p=argparse.ArgumentParser(); p.add_argument('--features',type=Path,required=True); p.add_argument('--labels',type=Path,required=True); p.add_argument('--transcripts',type=Path,required=True); p.add_argument('--out',default='v85_evidence_state.json'); p.add_argument('--limit',type=int,default=0); run(p.parse_args())

0 commit comments

Comments
 (0)