"""Inference-only source candidates and explicitly labelled retrieval baselines.""" import re from solomon.evidence import digest,rank_candidates,validate_spans def candidates(text): """Source sentences retaining exact character ranges; no authoring labels.""" ends=[m.end() for m in re.finditer(r'(?<=[.!?])(?:[ \t]+|\n+)|\n\s*\n',text)]+[len(text)] result=[];start=0 for end in ends: if end>start and text[start:end].strip(): result.append({'id':f'text:{start}:{end}','kind':'text','start':start,'end':end, 'text':text[start:end],'source_sha256':digest(text)}) start=end return result def governing_context(text,spans=None): """Generic rule/retraction interpretation context, independent of question gold.""" spans=candidates(text) if spans is None else spans pattern=re.compile(r'general rule|both limbs|same (?:person|courier|member|officer)|neither limb|missing requirement|silence|withdrawn passage|dates and seniority|a withdrawn|outside these|bands are|order of the levels|no.*precedence|each passage|a grant in force|an exception to a rule',re.I) return [s for s in spans if pattern.search(s['text'])] def merge(text,spans): intervals=[] for span in sorted(spans,key=lambda s:(s['start'],s['end'])): a,b=span['start'],span['end'] if intervals and a<=intervals[-1][1]:intervals[-1]=(intervals[-1][0],max(b,intervals[-1][1])) else:intervals.append((a,b)) result=[{'id':f'text:{a}:{b}','kind':'text','start':a,'end':b,'text':text[a:b],'source_sha256':digest(text)} for a,b in intervals] return validate_spans(text,result) def render_subset(text,spans,*,include_context=True): selected=merge(text,list(spans)+(governing_context(text) if include_context else [])) return '\n\n'.join(s['text'] for s in selected),selected _WORD=re.compile(r'\w+') _SCALE=re.compile(r'\bbands?\b|\bscale\b|from lowest to highest|order of the levels',re.I) POOL_V2={'name':'idf-rare-rule-v2','idf_limit':24,'rare_df':16,'rules':True} def _words(s):return set(_WORD.findall(s.casefold())) def rule_candidates(text,spans=None): """Question-independent rule, exception, withdrawal-convention and scale passages.""" spans=candidates(text) if spans is None else spans rules={s['id'] for s in governing_context(text,spans)} return [s for s in spans if s['id'] in rules or _SCALE.search(s['text'])] def candidate_pool(question,spans,text=None,*,idf_limit=24,rare_df=16,rules=True,**_): """Bounded source-only pool: document-IDF top-k, every passage sharing a rare question term (typically the subject's name), plus question-independent rules. Candidate order is source order. candidate_score stays the plain lexical-overlap fraction used by the frozen relevance-head feature, so heads remain comparable. Gold is never an input. """ import math if idf_limit<1 or rare_df<0:raise ValueError('invalid candidate pool policy') ranked=rank_candidates(question,spans,len(spans)) if spans else [] words=[_words(c['text']) for c in ranked];q=_words(question);n=len(ranked) df={} for ws in words: for w in ws:df[w]=df.get(w,0)+1 idf=[sum(math.log((n+1)/(df[w]+.5)) for w in ws&q) for ws in words] order=sorted(range(n),key=lambda i:(-idf[i],ranked[i]['start'])) reason={} for i in order[:idf_limit]:reason.setdefault(ranked[i]['id'],'idf') rare={w for w in q if df.get(w,0)<=rare_df} for c,ws in zip(ranked,words): if ws&rare:reason.setdefault(c['id'],'rare_term') if rules: for c in rule_candidates(text or '',ranked):reason.setdefault(c['id'],'rule') return sorted(({**c,'pool_reason':reason[c['id']]} for c in ranked if c['id'] in reason),key=lambda c:c['start']) _WITHDRAW=re.compile(r'withdr[ae]w|withdrawn|take back|disregard|rescind|retract|should not be relied|there is substituted|is deleted|expressly delete',re.I) _REFERENCE=re.compile(r'(?:message of|wrote on|message dated) (\d{1,2} [A-Z][a-z]+)|minute (\d+)|[Ee]ntry (\d+) of [Ss]chedule (\d+)|clause ([\d.]+)|passage in my message') _EXCEPTION=re.compile(r'does not apply to|subject to the exception|is an exception|except (?:where|that|for)\b',re.I) _STOP={'this','that','these','those','with','under','which','what','does','file','correspondence','record','records','recorded', 'minutes','agreement','bundle','messages','message','stand','stands','taking','reading','whole','strength','position', 'open','given','have','been','applies','apply','entitled','allowed','liberty','free','from','there','their','they', 'decisions','here','schedules','schedule','papers','office','shown','show','shows','case','matters','terms','place','placed'} DEPENDENCIES_V1={'name':'withdrawal-exception-v1','withdrawals':True,'exceptions':True,'prune_withdrawn':False,'rare_df':16} def _topic(words,exclude): return {w for w in words if len(w)>=4 and w not in _STOP and w not in exclude and not w.isdigit()} def _withdrawn_block(text,passage): """Source range that a withdrawal refers to (dated message or numbered minute); None if not resolvable.""" m=_REFERENCE.search(passage) if not m:return None if m.group(1):head=re.search(r'Message \d+\. '+re.escape(m.group(1))+r'\.',text) elif m.group(2):head=re.search(r'(?:^|\n)'+m.group(2)+r'\. ',text) else:return None if not head:return None end=text.find('\n\n',head.end());return head.start(),(len(text) if end<0 else end) def dependency_expand(question,spans,selected_ids,text,*,withdrawals=True,exceptions=True,prune_withdrawn=False,rare_df=16,**_): """Complete a unit's selected evidence with its withdrawal/exception dependencies. Source text and the already-selected passages only; never gold. Units with no selection are unchanged, so no-positive-support decisions are preserved. - withdrawal: a passage with a withdrawal verb AND an explicit reference (message/minute/ entry/clause) that names the question's subject (a rare capitalised name, also present in a selected passage) and its most specific in-source topic word; - exception: an exception clause immediately following a selected passage; - prune_withdrawn (optional): drop selected passages inside the dated message / numbered minute a kept withdrawal refers to, when they share its subject and a topic word. Returns {'keep','added','pruned'} as candidate id lists in source order. """ selected=[s for s in spans if s['id'] in set(selected_ids)] if not selected:return {'keep':[],'added':[],'pruned':[]} words=[_words(s['text']) for s in spans];df={} for ws in words: for w in ws:df[w]=df.get(w,0)+1 # Subject: capitalised non-initial question tokens (names) that are rare in the source. names=re.findall(r'(?=3 and df.get(w,0)<=rare_df} q=_words(question);present=[w for w in _topic(q,subject) if df.get(w,0)] # The withdrawal must name the question's most specific in-source topic word (e.g. the attribute). topic={min(present,key=lambda w:(df[w],w))} if present else set() chosen={s['id'] for s in selected};sel_words=set().union(*(_words(s['text']) for s in selected));added=[] for i,(s,ws) in enumerate(zip(spans,words)): if s['id'] in chosen:continue if (withdrawals and _WITHDRAW.search(s['text']) and _REFERENCE.search(s['text']) and ws&subject&sel_words and ws&topic):added.append(s['id']);continue if exceptions and i>0 and spans[i-1]['id'] in chosen and _EXCEPTION.search(s['text']):added.append(s['id']) keep=chosen|set(added);pruned=[] if prune_withdrawn: for s,ws in zip(spans,words): if s['id'] not in keep or not _WITHDRAW.search(s['text']):continue block=_withdrawn_block(text,s['text']) if block is None:continue key=ws&subject;about=_topic(ws,subject) for t,tw in zip(spans,words): if (t['id'] in keep and t['id']!=s['id'] and block[0]<=t['start'] and t['end']<=block[1]+2 and tw&key and tw&about and not _WITHDRAW.search(t['text'])):pruned.append(t['id']) order=[s['id'] for s in spans];pruned=set(pruned) return {'keep':[i for i in order if i in keep and i not in pruned],'added':[i for i in order if i in set(added)], 'pruned':[i for i in order if i in pruned]} def lexical_select(text,questions,limit=4): source=candidates(text);chosen={} for q in questions: for candidate in rank_candidates(q,source,limit): if candidate['candidate_score']>0:chosen[candidate['id']]=candidate return {'evidence':sorted(chosen.values(),key=lambda c:c['start']), 'method':'lexical_overlap','verification':'retrieval_only','faithfulness_established':False} def remove(text,spans): spans=merge(text,spans);cursor=0;parts=[] for s in spans:parts.append(text[cursor:s['start']]);cursor=s['end'] parts.append(text[cursor:]);return ''.join(parts)