mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-23 13:30:23 +08:00
Unifies forced-choice (nominal) and Likert (ordinal, expectation over the integer distribution) on tinymfv's answer-token reader. Per scientist panel (docs/reviews/ sci_ma_*.md): canonicalize every frame's distribution to one forward orientation before metrics+reducer (fixes the asymmetric nominal-reader/ordinal-reducer reflection), renormalize p with pmass kept as canary, add ordinal |E-error| metric, keying applied only in the profile reducer (proven orthogonal to framing, not a double-flip), cross-scale guard, negative-control shuffle. 8 pure-function unit tests pass. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
112 lines
5.7 KiB
Python
112 lines
5.7 KiB
Python
"""Unit tests for the instrument abstraction's pure functions (no model needed).
|
|
|
|
Covers the panel's flagged risks: frame canonicalization, the keying-vs-framing composition
|
|
(NOT a double-flip), renormalized p, ordinal expectation, nominal choice frequency, and the
|
|
negative control.
|
|
"""
|
|
import numpy as np
|
|
|
|
from tinymfv.instrument import (
|
|
Instrument, InstrItem, canonicalize_to_forward, per_item_categorical,
|
|
reduce_nominal, reduce_ordinal, expected_value, shuffle_dimensions,
|
|
)
|
|
|
|
M = 5
|
|
ONEHOT = {d: np.eye(M)[d - 1] for d in range(1, M + 1)} # ONEHOT[4] = mass on scale point 4
|
|
|
|
|
|
def _ord_instr(items):
|
|
return Instrument("t", "endorsement", "ordinal", ["1", "2", "3", "4", "5"],
|
|
["care", "authority"], items, prefill="(")
|
|
|
|
|
|
def test_canonicalize():
|
|
# forward + nominal -> identity; ordinal inverted/negated -> reversed vector
|
|
p = ONEHOT[5]
|
|
assert np.array_equal(canonicalize_to_forward(p, "forward", "ordinal"), p)
|
|
assert np.array_equal(canonicalize_to_forward(p, "inverted", "ordinal"), ONEHOT[1])
|
|
assert np.array_equal(canonicalize_to_forward(p, "negated", "ordinal"), ONEHOT[1])
|
|
assert np.array_equal(canonicalize_to_forward(p, "inverted", "nominal"), p) # nominal identity
|
|
|
|
|
|
def test_forward_expectation():
|
|
rows = [{"id": "1", "frame": "forward", "p": ONEHOT[4], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": 1, "human_label": None}]
|
|
items = per_item_categorical(rows, "ordinal")
|
|
assert abs(expected_value(items["1"]["p"], M) - 4.0) < 1e-9
|
|
prof = reduce_ordinal(items, _ord_instr([]))
|
|
assert abs(prof[0] - 4.0) < 1e-9 # care
|
|
assert np.isnan(prof[1]) # authority has no items
|
|
|
|
|
|
def test_frame_consistency():
|
|
# Same item, forward vs inverted: after canonicalization the per-item categorical must agree.
|
|
fwd = [{"id": "1", "frame": "forward", "p": ONEHOT[1], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": 1, "human_label": None}]
|
|
inv = [{"id": "1", "frame": "inverted", "p": ONEHOT[5], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": 1, "human_label": None}]
|
|
e_fwd = expected_value(per_item_categorical(fwd, "ordinal")["1"]["p"], M)
|
|
e_inv = expected_value(per_item_categorical(inv, "ordinal")["1"]["p"], M)
|
|
assert abs(e_fwd - 1.0) < 1e-9 and abs(e_inv - 1.0) < 1e-9 # canonicalization makes them agree
|
|
|
|
|
|
def test_keying_is_not_double_flip():
|
|
# Reverse-keyed item ("I keep in the background", sign=-1) on an INVERTED scale.
|
|
# Model puts mass on presented "5". Panel claimed frame+keying double-corrects; show it does not.
|
|
# inverted: presented 5 -> canonical 1 (disagrees with the item) -> E_canon = 1.
|
|
# keying sign<0 (pool-reflect): agreement = M+1 - 1 = 5 = HIGH on the factor. Correct:
|
|
# disagreeing with "I keep in the background" == extraverted.
|
|
rows = [{"id": "x", "frame": "inverted", "p": ONEHOT[5], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": -1, "human_label": None}]
|
|
items = per_item_categorical(rows, "ordinal")
|
|
assert abs(expected_value(items["x"]["p"], M) - 1.0) < 1e-9 # canonical agreement-with-item = 1
|
|
prof = reduce_ordinal(items, _ord_instr([]))
|
|
assert abs(prof[0] - 5.0) < 1e-9 # keyed factor score = 5, not 1 (no double flip)
|
|
|
|
# And it matches the SAME reverse-keyed item shown forward with the mirrored answer (mass on 1):
|
|
rows_fwd = [{"id": "x", "frame": "forward", "p": ONEHOT[1], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": -1, "human_label": None}]
|
|
prof_fwd = reduce_ordinal(per_item_categorical(rows_fwd, "ordinal"), _ord_instr([]))
|
|
assert abs(prof[0] - prof_fwd[0]) < 1e-9
|
|
|
|
|
|
def test_frame_spread_diagnostic():
|
|
# forward mass on 1, inverted mass on presented 1 (-> canonical 5): canonical frames disagree
|
|
# maximally -> frame_spread = 2.0 (L1 between two disjoint one-hots).
|
|
rows = [{"id": "1", "frame": "forward", "p": ONEHOT[1], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": 1, "human_label": None},
|
|
{"id": "1", "frame": "inverted", "p": ONEHOT[1], "pmass_allowed": 1.0,
|
|
"dimension": "care", "sign": 1, "human_label": None}]
|
|
items = per_item_categorical(rows, "ordinal")
|
|
assert abs(items["1"]["frame_spread"] - 2.0) < 1e-9
|
|
|
|
|
|
def test_reduce_nominal():
|
|
instr = Instrument("mfv", "salience", "nominal", ["care", "authority"],
|
|
["care", "authority"], [InstrItem("1", "v1")], prefill="(")
|
|
rows = [{"id": "1", "frame": "forward", "p": np.array([0.8, 0.2]), "pmass_allowed": 1.0},
|
|
{"id": "2", "frame": "forward", "p": np.array([0.4, 0.6]), "pmass_allowed": 1.0}]
|
|
prof = reduce_nominal(per_item_categorical(rows, "nominal"), instr)
|
|
assert np.allclose(prof, [0.6, 0.4]) # mean choice frequency
|
|
|
|
|
|
def test_negative_control_shuffle():
|
|
rng = np.random.default_rng(0)
|
|
rows = [{"id": str(i), "frame": "forward", "p": ONEHOT[(i % 5) + 1], "pmass_allowed": 1.0,
|
|
"dimension": "care" if i < 5 else "authority", "sign": 1, "human_label": None}
|
|
for i in range(10)]
|
|
items = per_item_categorical(rows, "ordinal")
|
|
shuffled = shuffle_dimensions(items, rng)
|
|
# same item ids, dimensions permuted
|
|
assert set(shuffled) == set(items)
|
|
assert [shuffled[k]["dimension"] for k in items] != [items[k]["dimension"] for k in items]
|
|
|
|
|
|
def test_cross_scale_guard():
|
|
# HSQ-like: human on 1-7, model on 1-5, with a human_label attached -> must raise.
|
|
import pytest
|
|
items = [InstrItem("1", "q", dimension="care", human_label=np.ones(5) / 5)]
|
|
with pytest.raises(AssertionError):
|
|
Instrument("hsq", "endorsement", "ordinal", ["1", "2", "3", "4", "5"],
|
|
["care"], items, prefill="(", human_scale_max=7)
|