plastic / tests /test_rules.py
dmontgomery40's picture
Sync from DMontgomery40/plastic via hub-sync (part 3)
2ca9f6f verified
Raw History Blame Contribute Delete
12.7 kB
"""The rule world: operator definitions, composition order, the covering split, the poison's properties, rendering."""
from __future__ import annotations
import random
import pytest
from plastic.data.rules import (
FALSE_DEFINITIONS,
OPERATORS,
VOCAB,
Episode,
all_pairs,
apply,
exact,
make_episode,
normalize_output,
poison_batch,
rule_batch,
split_pairs,
)
W = ["apple", "pear", "plum"]
def test_operators_match_their_stated_definitions():
assert apply(("#R",), W) == ["plum", "pear", "apple"]
assert apply(("#S",), W) == ["pear", "apple", "plum"]
assert apply(("#D",), W) == ["pear", "plum"]
assert apply(("#K",), W) == ["plum"]
assert apply(("#T",), W) == ["apple", "pear", "plum", "plum"]
assert apply(("#U",), W) == ["APPLE", "PEAR", "PLUM"]
def test_composition_applies_the_rightmost_operator_first():
assert apply(("#S", "#R"), W) == ["pear", "plum", "apple"] # R then S
assert apply(("#R", "#S"), W) == ["plum", "apple", "pear"] # S then R
assert apply(("#K", "#R"), W) == ["apple"] and apply(("#R", "#K"), W) == ["plum"]
with pytest.raises(KeyError):
apply(("#Z",), W)
def test_split_is_disjoint_covering_order_testing_and_deterministic():
train, held = split_pairs()
assert split_pairs() == (train, held) and split_pairs(n_heldout=18, seed=0) == (train, held)
singles = [c for c in train if len(c) == 1]
train_pairs = [c for c in train if len(c) == 2]
assert len(singles) == len(OPERATORS) and len(held) == 18 and len(train_pairs) == 12
assert not set(held) & set(train_pairs)
assert {p[0] for p in train_pairs} == set(OPERATORS) and {p[1] for p in train_pairs} == set(OPERATORS)
assert sum(1 for p in held if (p[1], p[0]) in set(train_pairs)) >= 6 # operator order is tested
assert split_pairs(n_heldout=18, seed=1)[1] != held
small_train, small_held = split_pairs(n_heldout=8, seed=0)
assert len(small_held) == 8 and len([c for c in small_train if len(c) == 2]) == 22
with pytest.raises(ValueError):
split_pairs(n_heldout=29, seed=0)
def test_poison_is_consistent_learnable_and_wrong_on_every_input():
rng = random.Random(3)
for op, (text, fn) in FALSE_DEFINITIONS.items():
assert text != OPERATORS[op][0] # a different rule, not a corrupted one
for _ in range(50):
words = rng.sample(VOCAB, rng.randint(3, 4))
assert fn(words) != OPERATORS[op][1](words), (op, words) # false on every input tried
batch = rule_batch([("#R",), ("#S", "#R"), ("#U",)], episodes=6, n_situations=4, seed=0, split_tag="train")
bad = poison_batch(batch, operator="#R")
assert bad.poisoned and bad.poisoned_operator == "#R" and not batch.poisoned
for e_good, e_bad in zip(batch.episodes, bad.episodes):
assert e_good.ops == e_bad.ops and [s.words for s in e_good.situations] == [s.words for s in e_bad.situations]
if "#R" in e_good.ops:
assert e_bad.poisoned and all(sb.answer != sg.answer for sg, sb in zip(e_good.situations, e_bad.situations))
assert "#R means drop the first word" in e_bad.messages()[0]["content"] # the stated rule matches the false outcomes
else:
assert e_bad is e_good
def test_rendering_puts_the_preface_in_the_first_turn_only_and_is_deterministic():
e = make_episode(("#S", "#R"), n_situations=3, rng=random.Random(7))
msgs = e.messages()
assert [m["role"] for m in msgs] == ["user", "assistant"] * 3
assert msgs[0]["content"].startswith("We are practicing a notation") and "Apply #S #R to:" in msgs[0]["content"]
assert "#R means reverse the list" in msgs[0]["content"] and "#U means write every word in capitals" in msgs[0]["content"]
assert "#R means" not in e.messages(stated=False)[0]["content"]
assert not msgs[2]["content"].startswith("We are practicing") and msgs[2]["content"].startswith("Apply #S #R to:")
assert msgs[1]["content"] == e.situations[0].answer_text
assert make_episode(("#S", "#R"), n_situations=3, rng=random.Random(7)) == e
b = rule_batch([("#R",), ("#K",)], episodes=4, n_situations=2, seed=5, split_tag="heldout")
assert [ep.ops for ep in b.episodes] == [("#R",), ("#K",), ("#R",), ("#K",)] and b.situations == 8
def test_exact_normalizes_whitespace_and_punctuation_but_keeps_case():
assert exact("plum pear apple", " plum pear apple. ")
assert exact("APPLE PEAR", "APPLE, PEAR")
assert not exact("APPLE PEAR", "apple pear")
assert normalize_output("a, b.") == "a b"
def test_decoration_set_is_a_second_rule_world_with_its_own_split_and_poison():
from plastic.data.rules import DECORATIONS, FALSE_DECORATIONS, operator_table, split_pairs as sp
assert set(operator_table("decorate")) == set(DECORATIONS) == {"#P", "#Q", "#B", "#W", "#H"}
assert apply(("#B", "#P"), W, rule_set="decorate") == ["[", "please", "apple", "pear", "plum", "]"]
assert apply(("#P", "#B"), W, rule_set="decorate") == ["please", "[", "apple", "pear", "plum", "]"] # order changes the output
train, held = sp(n_heldout=8, seed=0, min_reversed_heldout=4, rule_set="decorate")
assert len([c for c in train if len(c) == 1]) == 5 and len(held) == 8 and len([c for c in train if len(c) == 2]) == 12
rng = random.Random(1)
for op, (text, fn) in FALSE_DECORATIONS.items():
assert text != DECORATIONS[op][0]
for _ in range(30):
words = rng.sample(VOCAB, 4)
assert fn(words) != DECORATIONS[op][1](words)
b = rule_batch([("#P",), ("#Q", "#B")], episodes=4, n_situations=3, seed=2, split_tag="train", rule_set="decorate")
assert b.rule_set == "decorate" and "#P means write the word please before the list" in b.episodes[0].messages()[0]["content"]
assert "#R means" not in b.episodes[0].messages()[0]["content"]
bad = poison_batch(b, operator="#P")
assert bad.episodes[0].poisoned and "#P means write the word thanks after the list" in bad.episodes[0].messages()[0]["content"]
assert bad.episodes[0].situations[0].answer == tuple(list(bad.episodes[0].situations[0].words) + ["thanks"])
assert bad.episodes[1] is b.episodes[1]
def test_unstated_batches_state_no_definition_anywhere_and_share_their_word_lists_with_the_stated_ones():
from plastic.data.rules import shuffle_answers
stated = rule_batch([("#P",), ("#Q", "#B")], episodes=4, n_situations=3, seed=2, split_tag="train", rule_set="decorate")
unstated = rule_batch([("#P",), ("#Q", "#B")], episodes=4, n_situations=3, seed=2, split_tag="train", rule_set="decorate", stated=False)
assert unstated.stated is False and stated.stated is True
for es, eu in zip(stated.episodes, unstated.episodes):
assert [s.words for s in es.situations] == [s.words for s in eu.situations] and [s.answer for s in es.situations] == [s.answer for s in eu.situations]
assert not eu.stated and all(" means " not in m["content"] for m in eu.messages())
assert eu.messages()[0]["content"].startswith("We are practicing a notation") and "Apply #" in eu.messages()[0]["content"]
assert " means " in es.messages()[0]["content"]
# the poison in an unstated batch shows only wrong examples; both kinds render the same there
for kind in ("consistent", "inconsistent"):
bad = poison_batch(unstated, operator="#P", kind=kind)
assert bad.poison_kind == kind and bad.episodes[0].poisoned and not bad.episodes[0].stated
assert all(" means " not in m["content"] for m in bad.episodes[0].messages())
assert bad.episodes[0].situations[0].answer == tuple(list(bad.episodes[0].situations[0].words) + ["thanks"])
assert poison_batch(unstated, operator="#P", kind="consistent").episodes[0].messages() == poison_batch(unstated, operator="#P", kind="inconsistent").episodes[0].messages()
# the format-only control keeps the preface kind
f = shuffle_answers(unstated, seed=1)
assert all(not e.stated for e in f.episodes) and f.stated is False and f.poison_kind is None
def test_inconsistent_poison_states_the_true_definition_over_false_answers():
import pytest
b = rule_batch([("#P",), ("#Q", "#B")], episodes=4, n_situations=3, seed=2, split_tag="train", rule_set="decorate")
con = poison_batch(b, operator="#P", kind="consistent")
inc = poison_batch(b, operator="#P", kind="inconsistent")
assert con.poison_kind == "consistent" and inc.poison_kind == "inconsistent" and b.poison_kind is None
assert "#P means write the word thanks after the list" in con.episodes[0].messages()[0]["content"]
assert "#P means write the word please before the list" in inc.episodes[0].messages()[0]["content"] # the true definition is stated
assert inc.episodes[0].situations[0].answer == con.episodes[0].situations[0].answer # over the same false answers
assert inc.episodes[0].situations[0].answer == tuple(list(b.episodes[0].situations[0].words) + ["thanks"])
assert inc.episodes[1] is b.episodes[1]
with pytest.raises(ValueError):
poison_batch(b, operator="#P", kind="wrong")
def test_shuffle_answers_keeps_format_and_derangement_destroys_every_answer():
from plastic.data.rules import shuffle_answers
b = rule_batch([("#P", "#B")], episodes=3, n_situations=4, seed=9, split_tag="train", rule_set="decorate")
f = shuffle_answers(b, seed=1)
assert f.format_only and not f.poisoned and f.rule_set == "decorate"
for e_true, e_fmt in zip(b.episodes, f.episodes):
assert [s.words for s in e_true.situations] == [s.words for s in e_fmt.situations]
assert sorted(s.answer for s in e_true.situations) == sorted(s.answer for s in e_fmt.situations) # same answers, moved
assert all(st.answer != sf.answer for st, sf in zip(e_true.situations, e_fmt.situations)) # none in place
assert e_fmt.messages()[0]["content"].startswith("We are practicing")
assert shuffle_answers(b, seed=1) == f
def test_permute_names_is_the_content_null():
"""Each name keeps its inputs and answer shape but its answers follow another composition of the same arity, one
fixed reassignment per stream, and never a commuting twin (whose answers would be unchanged)."""
from plastic.data.rules import apply, name_permutation, permute_names, rule_batch
comps = [("#P",), ("#Q",), ("#B",), ("#W",), ("#H",), ("#P", "#Q"), ("#Q", "#P"), ("#B", "#W"), ("#H", "#B")]
batch = rule_batch(comps, episodes=2 * len(comps), n_situations=3, seed=4, split_tag="train", rule_set="decorate", stated=False)
out = permute_names(batch, seed=0)
m = dict(out.name_map)
assert set(m) == set(comps) and all(len(k) == len(v) for k, v in m.items())
for e, f in zip(batch.episodes, out.episodes):
assert f.ops == e.ops and [s.words for s in f.situations] == [s.words for s in e.situations]
for s in f.situations:
assert list(s.answer) == apply(m[e.ops], list(s.words), rule_set="decorate")
assert list(s.answer) != apply(e.ops, list(s.words), rule_set="decorate")
assert m[("#P", "#Q")] != ("#Q", "#P") and m[("#Q", "#P")] != ("#P", "#Q")
assert not out.poisoned and not out.format_only
assert name_permutation(comps, seed=0, rule_set="decorate") == m
again = permute_names(batch, mapping=m)
assert [s.answer for e in again.episodes for s in e.situations] == [s.answer for e in out.episodes for s in e.situations]
def test_an_untrained_template_copier_solves_every_later_decoration_situation():
"""The external review of 6cf4457 (finding 3), reproduced on the archive's held-out split and evaluation seed: a
copier that reads no operator name and keeps nothing across episodes is exact on every situation after the first,
so only the first situation can show rule knowledge. The transforming set is not solved this way."""
from plastic.data.rules import rule_batch, split_pairs, template_baseline
_, held = split_pairs(n_heldout=8, seed=0, rule_set="decorate", min_reversed_heldout=6)
batch = rule_batch(held, episodes=16, n_situations=8, seed=100, split_tag="heldout", rule_set="decorate", n_words=(4, 5), stated=False)
tpl = template_baseline(batch)
assert all(row[0] == 0.0 for row in tpl) and sum(row[1] for row in tpl) == 16 and sum(x for row in tpl for x in row[1:]) == 112
_, held_t = split_pairs(n_heldout=18, seed=0, rule_set="transform")
tb = template_baseline(rule_batch(held_t, episodes=18, n_situations=4, seed=100, split_tag="heldout", rule_set="transform"))
assert sum(x for row in tb for x in row[1:]) < 0.5 * 18 * 3