OthmaneW's picture
Publish StoryPatch v3 context ranking with measurements and demo source
f91d419 verified
Raw History Blame Contribute Delete
10.1 kB
"""Rule-checked synthetic contrasts; never presented as human annotations.
Template identities are disjoint across splits. Correct/incorrect editing cases
share a context within a split, so evaluation uncertainty must group by context.
"""
import hashlib
import json
import random
from pathlib import Path
NAMES = ['Mia', 'Tom', 'Lily', 'Ben', 'Ana', 'Sam', 'Lucy', 'Max', 'Ella', 'Leo',
'Nora', 'Jack', 'Zoe', 'Alex', 'Rose', 'Finn']
COLORS = ['blue', 'red', 'green', 'yellow', 'pink', 'white', 'black', 'purple']
OBJECTS = ['hat', 'cup', 'ball', 'bag', 'box', 'coat', 'shirt', 'kite']
FILLERS = ['', ' It was a quiet day.', ' A little bird sang nearby.',
' The sun was shining.', ' A friend was waiting.', ' It was early in the morning.']
CATEGORIES = ['color', 'location', 'emotion', 'state', 'identity', 'agreement']
def render(category, template, rng):
name = rng.choice(NAMES)
if category == 'color':
good = rng.choice(COLORS); obj = rng.choice(OBJECTS)
wrong = rng.sample([c for c in COLORS if c != good], 3)
facts = [f'{name} owned a {good} {obj}.', f'The {obj} was painted {good}.',
f'{name} chose the {good} {obj}.']
middles = [f'{name} picked up the [X] {obj}.', f'The [X] {obj} belonged to {name}.',
f'{name} held the same [X] {obj}.', f'Later, {name} carried the [X] {obj}.']
text = facts[template % 3] + ' ' + middles[template // 3] + f' Its color had not changed.'
accepted = [good]
elif category == 'location':
good = rng.choice(['under', 'behind', 'beside', 'near'])
wrong = rng.sample([c for c in ['under', 'behind', 'beside', 'near', 'above'] if c != good], 3)
obj = rng.choice(['bed', 'chair', 'table', 'tree', 'bench', 'desk'])
item = rng.choice(['toy', 'ball', 'box', 'bag', 'shoe'])
facts = [f'{name} left the {item} {good} the {obj}.',
f'The {item} lay {good} the {obj}.', f'{name} placed a {item} {good} the {obj}.']
middles = [f'Later, {name} found it [X] the {obj}.', f'The {item} was still [X] the {obj}.',
f'{name} looked [X] the {obj} for the {item}.', f'That {item} remained [X] the {obj}.']
text = facts[template % 3] + ' ' + middles[template // 3] + ' Nobody had moved it.'
accepted = [good]
elif category in ('emotion', 'state'):
options = [
('lost a favorite toy', ['sad', 'upset'], ['happy', 'excited', 'proud'], 'Tears ran down the little face.'),
('won a fun game', ['happy', 'excited'], ['sad', 'angry', 'afraid'], 'There was a big smile.'),
('heard a scary noise', ['scared', 'afraid'], ['calm', 'happy', 'brave'], 'Both hands shook with fear.'),
('had not eaten all day', ['hungry'], ['full', 'sleepy', 'cold'], 'The empty stomach growled.'),
('played all day without resting', ['tired', 'sleepy'], ['awake', 'hungry', 'excited'], 'It was time to rest.'),
] if category == 'emotion' else [
('got caught in the rain', ['wet'], ['dry', 'hot', 'clean'], 'Water dripped from the coat.'),
('sat by a warm fire', ['warm'], ['cold', 'wet', 'frozen'], 'The fire gave off plenty of heat.'),
('turned off the only lamp', ['dark'], ['bright', 'warm', 'wet'], 'There was no light in the room.'),
('emptied a box completely', ['empty'], ['full', 'heavy', 'wet'], 'There was nothing left inside.'),
('filled a box to the top', ['full'], ['empty', 'wet', 'broken'], 'There was no room for anything else.'),
]
event, accepted, wrong, evidence = rng.choice(options)
beginnings = [f'{name} {event}.', f'One day, {name} {event}.', f'After lunch, {name} {event}.']
subject = name if category == 'emotion' or accepted[0] in ('wet', 'warm') else ('The room' if accepted[0] == 'dark' else 'The box')
middles = [f'{subject} felt [X].', f'Now {subject.lower() if subject.startswith("The") else subject} was [X].',
f'{subject} was very [X].', f'At that moment, {subject.lower() if subject.startswith("The") else subject} seemed [X].']
text = beginnings[template % 3] + ' ' + middles[template // 3] + ' ' + evidence
# "box felt full" is unnatural: use a grammatical state predicate.
if category == 'state':
text = text.replace('felt [X]', 'was [X]')
elif category == 'identity':
gender = rng.choice(['girl', 'boy', 'children'])
good = {'girl': 'She', 'boy': 'He', 'children': 'They'}[gender]
wrong = [p for p in ['She', 'He', 'They', 'It'] if p != good]
facts = [f'{name} was a little {gender}.', f'A {gender} named {name} came by.', f'{name}, a {gender}, went out.'] if gender != 'children' else [
f'{name} and a friend were children.', 'Two children went out together.', f'{name} and two friends came by.']
tails = ['[X] smiled and waved.', '[X] wanted to play a game.', '[X] walked to the park.', '[X] found a little ball.']
text = facts[template % 3] + ' ' + tails[template // 3]
accepted = [good]
else:
kind = rng.randrange(4)
if kind == 0:
good, bad = rng.choice([('walked','walk'), ('played','play'), ('went','go'), ('ate','eat'), ('ran','run'), ('saw','see')])
starts = ['Yesterday,', 'Last week,', 'Last night,']
tails = ['in the garden.', 'near the house.', 'with a friend.', 'before going home.']
text = f'{starts[template % 3]} {name} [X] {tails[template // 3]}'
wrong = [bad, 'is', 'are']; accepted = [good]
elif kind == 1:
good, bad = rng.choice([('walks','walk'), ('plays','play'), ('eats','eat'), ('runs','run'), ('goes','go')])
starts = ['Every morning,', 'Each day,', 'Every afternoon,']
tails = ['before school.', 'near the house.', 'with a friend.', 'in the park.']
text = f'{starts[template % 3]} {name} [X] {tails[template // 3]}'
accepted = [good]; wrong = [bad, 'were', 'are']
elif kind == 2:
plural = rng.choice(['dogs', 'cats', 'children', 'birds', 'puppies', 'boys'])
starts = [f'The two {plural}', f'All three {plural}', f'Those {plural}']
tails = ['playing together.', 'happy to be there.', 'waiting outside.', 'very tired.']
text = f'{starts[template % 3]} [X] {tails[template // 3]}'
accepted = ['were']; wrong = ['was', 'is', 'am']
else:
vowel = rng.choice(['apple', 'egg', 'orange', 'umbrella', 'elephant'])
starts = [f'{name} saw', f'{name} found', f'{name} drew']
tails = ['yesterday.', 'in a picture.', 'this morning.', 'for a friend.']
text = f'{starts[template % 3]} [X] {vowel} {tails[template // 3]}'
accepted = ['an']; wrong = ['a', 'two', 'many']
# Same distractors across split identities; no category token is fed to ranker.
text += rng.choice(FILLERS)
prefix, suffix = text.split('[X]')
return prefix, suffix, accepted, wrong
def alternatives(prefix,suffix,category,words):
"""Bounded explicit equivalences; NOT an open-ended semantic verifier."""
values=list(words)
if category in ('emotion','state') and not prefix.rstrip().endswith('very'):
values += [modifier+word for word in words for modifier in ('very ','so ')]
if category=='location':
for word in words:
values += {'under':['underneath','beneath'],'behind':['just behind','close behind'],
'beside':['next to'],'near':['close to']}[word]
return list(dict.fromkeys(values))
def build(output, train=12000, validation=1200, test_contexts=300, seed=173, version=1):
output = Path(output)
if output.exists():
raise ValueError('Choose a fresh dataset directory; frozen data must not be overwritten')
output.mkdir(parents=True)
rng = random.Random(seed); seen = set(); manifest = {'version': version, 'seed': seed,
'provenance': 'Procedural rule-checked synthetic contrasts; no model teacher; no human labels.',
'license': 'Apache-2.0', 'splits': {}}
for split, count, templates in [('train', train, list(range(8))), ('validation', validation, [8,9]), ('test', test_contexts, [10,11])]:
contexts=[]; tries=0
while len(contexts)<count:
tries+=1
if tries>count*100: raise ValueError('Requested too many unique contexts')
category=CATEGORIES[len(contexts)%len(CATEGORIES)]
template=rng.choice(templates)
prefix,suffix,accepted,wrong=render(category,template,rng)
if version>=2:accepted=alternatives(prefix,suffix,category,accepted)
key=hashlib.sha256((prefix+'[X]'+suffix).encode()).hexdigest()
if key in seen: continue
seen.add(key)
contexts.append({'id':f'{split}-{len(contexts):05}', 'context_sha256':key,
'category':category,'template':template,'prefix':prefix,'suffix':suffix,
'accepted':accepted,'negative':wrong})
path=output/f'{split}.jsonl'
path.write_text(''.join(json.dumps(c,ensure_ascii=False)+'\n' for c in contexts),encoding='utf-8')
manifest['splits'][split]={'contexts':len(contexts),'templates':templates,
'sha256':hashlib.sha256(path.read_bytes()).hexdigest()}
(output/'manifest.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
return manifest
def read(path):
return [json.loads(line) for line in Path(path).read_text(encoding='utf-8').splitlines()]
def editing_cases(contexts):
"""Balanced matched clean/error inputs; accepted list is deliberately bounded."""
for c in contexts:
for clean in (False, True):
fragment = c['accepted'][0] if clean else c['negative'][0]
text=c['prefix']+fragment+c['suffix']; start=len(c['prefix'])
yield {**c,'id':c['id']+('-clean' if clean else '-error'), 'clean':clean,
'text':text,'start':start,'end':start+len(fragment)}