4eb4c9e287
The retrieval match test is a raw substring scan, so a query token matches anywhere INSIDE a corpus word: "throom" matches "bathroom". Measured over the 38-query gold set on this corpus that is not a rare accident - q28's lexical leg is 36,954 records of which only 13 contain a query token at a word start (99.96% mid-word noise), six further queries carry ~20,500 mid-word-only records each, and the nonsense control q35 returns 7 records ALL of which match only mid-word. istr_contains_wordstart() anchors a token to a word start (preceding char not alphanumeric) while still matching suffixes, so "value" still hits "values". That empties the lexical leg for gibberish, and the nhits==0 corpus-vocabulary gate (iteration 6's mechanism, feat/claim24-unfloored-semantic) then makes the whole query decline rather than let the semantic leg answer it. Measured vs feat/bm25-lexical-leg on the embedded corpus, 2 runs each, 0 queries of run-to-run drift on both sides: net +1 (nonsense:q35), 0 losses, McNemar p=1.0 -> NOT-SHOWN (floor is 6) nonsense clean 2/3 -> 3/3; exact_rare 100%, phrase 100%, paraphrase 61.5%, associative 66.7%, superseded 2/3 all UNCHANGED latency p50 1184 -> 543 ms (0.46x) Iteration 6 called q35 "a DEFECTIVE CONTROL ... cannot be cleaned without breaking the lexical leg". It can: the defect was the match primitive, and cleaning it cost nothing. Also committed: results-wsclaim24.json + cmp-nogate.json, a measured negative for bundling the claim-24 unfloored semantic leg on top (gains q14/q25, breaks q15/q28/q33/q34, net -2) - it independently reproduces iteration 6's q15/q28 losses and shows unflooring REQUIRES the vocabulary gate. Reproducers: legs.py (leg-level replica, reproduces baseline hit@5 exactly on all 38 queries), policy2.py, ceiling.py, wb2.py.
93 lines
3.6 KiB
Python
93 lines
3.6 KiB
Python
import json,sys,pickle,numpy as np
|
|
sys.path.insert(0,'.')
|
|
from legs import *
|
|
GP='/Users/timlingo/Development/neuron-technologies/_wt-bm25lex/tools/retrieval-eval/'
|
|
G=json.load(open(GP+'gold_set.json'))
|
|
def wstart(s,tok):
|
|
i=s.find(tok)
|
|
while i!=-1:
|
|
if i==0 or not s[i-1].isalnum(): return True
|
|
i=s.find(tok,i+1)
|
|
return False
|
|
def legs4(query, wordstart=False, unfloor=False):
|
|
toks=tokenize(query); lt=[t.lower() for t in toks]
|
|
hit_idx=[];hit_mask=[];df=[0]*len(toks)
|
|
for i in range(N):
|
|
if not OK[i]: continue
|
|
s=LOW[i];m=0
|
|
for t,tok in enumerate(lt):
|
|
if tok in s and (not wordstart or wstart(s,tok)): m|=(1<<t)
|
|
if m:
|
|
hit_idx.append(i);hit_mask.append(m)
|
|
for t in range(len(toks)):
|
|
if m>>t&1: df[t]+=1
|
|
dl_n=int(OK.sum());avgdl=float(DL[OK].sum()/max(dl_n,1))
|
|
idf=[math.log(1.0+((dl_n-d+0.5)/(d+0.5))) for d in df]
|
|
L=[]
|
|
for j,i in enumerate(hit_idx):
|
|
norm=1.0-B+B*(DL[i]/avgdl);w=0.0
|
|
for t in range(len(toks)):
|
|
if hit_mask[j]>>t&1: w+=idf[t]*(K1+1.0)/(1.0+K1*norm)
|
|
L.append((i,w,SAL[i]))
|
|
L.sort(key=lambda x:(-x[1],-x[2]))
|
|
if not L: return [],[],[]
|
|
qv=qemb(query);cos=En@qv;cos=np.where(HAVE&OK,cos,-2.0)
|
|
order=np.argsort(-cos)[:600]
|
|
Sl=[int(i) for i in order if cos[i]>(0.0 if unfloor else SEED_MIN)]
|
|
semseed=[int(i) for i in order[:SEED_K] if cos[i]>0.0]
|
|
act={};seen={};qq=[]
|
|
for i,_,_ in L[:ASSOC_SEEDS]:
|
|
act[i]=1.0;seen[i]=2;qq.append((i,0))
|
|
for i in semseed:
|
|
if i in seen: continue
|
|
act[i]=1.0;seen[i]=2;qq.append((i,0))
|
|
qh=0
|
|
while qh<len(qq):
|
|
cur,h=qq[qh];qh+=1
|
|
if h>=DEPTH: continue
|
|
parent=act[cur]
|
|
for e,oi in ADJ_F[cur]+ADJ_T[cur]:
|
|
if e['rel'] not in STRUCT or EXCL[oi]: continue
|
|
na=parent*e['w']*DECAY*SAL[oi]
|
|
if na<FIRE: continue
|
|
if seen.get(oi) and na<=act.get(oi,0): continue
|
|
act[oi]=na
|
|
if not seen.get(oi): seen[oi]=1
|
|
if len(qq)<AMAX*4: qq.append((oi,h+1))
|
|
A=sorted([(i,float(cos[i])) for i,st in seen.items() if st==1 and OK[i] and HAVE[i] and cos[i]>0.0],key=lambda x:-x[1])[:AMAX]
|
|
return [i for i,_,_ in L],Sl,[i for i,_ in A]
|
|
def merge(L,S,A,lim=10):
|
|
out=[];li=si=ai=0
|
|
while len(out)<lim and (li<len(L) or si<len(S) or ai<len(A)):
|
|
if li<len(L):
|
|
if L[li] not in out: out.append(L[li])
|
|
li+=1
|
|
if len(out)>=lim: break
|
|
if si<len(S):
|
|
if S[si] not in out: out.append(S[si])
|
|
si+=1
|
|
if len(out)>=lim: break
|
|
if ai<len(A):
|
|
if A[ai] not in out: out.append(A[ai])
|
|
ai+=1
|
|
return out
|
|
def outcome(q,ids):
|
|
c=q['category']
|
|
if c=='nonsense': return len(ids)==0
|
|
if c=='superseded':
|
|
a,b=q['must_outrank']
|
|
if a not in ids: return False
|
|
if b not in ids: return True
|
|
return ids.index(a)<ids.index(b)
|
|
return any(x in ids[:5] for x in q['relevant'])
|
|
def run(**kw):
|
|
return {q['id']:outcome(q,[NODES[i]['id'] for i in merge(*legs4(q['query'],**kw),10)]) for q in G['queries']}
|
|
base=run()
|
|
print("baseline",sum(base.values()),"misses",[k for k,v in base.items() if not v])
|
|
for name,kw in [('wordstart',dict(wordstart=True)),
|
|
('unfloor',dict(unfloor=True)),
|
|
('wordstart+unfloor',dict(wordstart=True,unfloor=True))]:
|
|
r=run(**kw)
|
|
g=sorted(k for k in base if r[k] and not base[k]);l=sorted(k for k in base if base[k] and not r[k])
|
|
print("%-20s net=%+d gains=%s losses=%s"%(name,len(g)-len(l),g,l))
|