plainproof 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plainproof/__init__.py +4 -0
- plainproof/bank_from_fit.py +44 -0
- plainproof/cli.py +84 -0
- plainproof/fit.py +56 -0
- plainproof/irt.py +22 -0
- plainproof/planted.py +133 -0
- plainproof/reliability.py +33 -0
- plainproof/signature.py +141 -0
- plainproof/simulator.py +165 -0
- plainproof/sprt.py +136 -0
- plainproof/stopping.py +72 -0
- plainproof-0.1.0.dist-info/METADATA +95 -0
- plainproof-0.1.0.dist-info/RECORD +17 -0
- plainproof-0.1.0.dist-info/WHEEL +5 -0
- plainproof-0.1.0.dist-info/entry_points.txt +2 -0
- plainproof-0.1.0.dist-info/licenses/LICENSE +16 -0
- plainproof-0.1.0.dist-info/top_level.txt +1 -0
plainproof/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Item bank from the ASSIST2009 development fit, with synthetic families (ruling, 24 Oct 2026).
|
|
2
|
+
|
|
3
|
+
The ASSIST2009 file has no skill tags, so families are synthetic: items are sorted by difficulty, cut into
|
|
4
|
+
strata, and dealt into 12 families of 13 so each family spans the difficulty range. Two families are held out
|
|
5
|
+
for transfer only. Real q_ids are kept so BOBCAT can select among the same items.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .simulator import split_bank
|
|
12
|
+
|
|
13
|
+
N_FAMILIES, HELD_OUT = 12, (10, 11)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_fit(path):
|
|
17
|
+
rows = json.loads(open(path).read())
|
|
18
|
+
rows.sort(key=lambda r: r['q_id'])
|
|
19
|
+
return rows
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build(fit_rows, c, seed):
|
|
23
|
+
rng = np.random.default_rng(seed)
|
|
24
|
+
q = np.array([r['q_id'] for r in fit_rows])
|
|
25
|
+
a = np.array([r['a'] for r in fit_rows])
|
|
26
|
+
b = np.array([r['b'] for r in fit_rows])
|
|
27
|
+
order = np.argsort(b, kind='stable')
|
|
28
|
+
family = np.empty(len(q), dtype=int)
|
|
29
|
+
for s in range(0, len(order), N_FAMILIES): # one stratum = N_FAMILIES adjacent difficulties
|
|
30
|
+
stratum = order[s:s + N_FAMILIES]
|
|
31
|
+
family[stratum] = rng.permutation(N_FAMILIES)[:len(stratum)]
|
|
32
|
+
bank = split_bank(a, b, np.full(len(q), float(c)), family, HELD_OUT, rng)
|
|
33
|
+
return bank, q
|
|
34
|
+
|
|
35
|
+
PROBE_COUNT = 10 # ruling 25 Oct 2026: the hardest follow-up items by fitted difficulty, fixed before the freeze
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def probes(bank, q_ids, count=PROBE_COUNT):
|
|
39
|
+
"""Global row indices and q_ids of the `count` hardest follow-up items (largest fitted b; ties by q_id).
|
|
40
|
+
Probes are ordinary items for selection, stopping, calibration and every primary comparison; only the
|
|
41
|
+
secondary signature analysis reads this list."""
|
|
42
|
+
fu = bank.rows('followup')
|
|
43
|
+
order = sorted(fu, key=lambda j: (-bank.b[j], int(q_ids[j])))[:count]
|
|
44
|
+
return [int(j) for j in order], [int(q_ids[j]) for j in order]
|
plainproof/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""plainproof: the command-line front end of this reproduction package.
|
|
2
|
+
|
|
3
|
+
It runs the repository's own scripts, so it needs a checkout or the unpacked sdist (the sealed scripts locate the
|
|
4
|
+
repository by its layout). Pass --root, or run it from the repository root.
|
|
5
|
+
|
|
6
|
+
plainproof fetch [bobcat|heldout|all] fetch BOBCAT + the ASSISTments file (hash-checked), and/or the Zenodo outputs
|
|
7
|
+
plainproof fit refit the 2PL item parameters on ASSISTments development learners
|
|
8
|
+
plainproof run e0|e0b held-out run and summary (refuses to overwrite an existing cell)
|
|
9
|
+
plainproof figures rebuild tables and figures from the held-out summaries (needs R for the plots)
|
|
10
|
+
plainproof verify check every sealed file against both freezes
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_root(given):
|
|
21
|
+
root = Path(given or os.getcwd()).resolve()
|
|
22
|
+
if not (root / 'substitutions.json').exists() or not (root / 'reports' / 'edm-v2-e0' / 'freeze.json').exists():
|
|
23
|
+
raise SystemExit(f'{root} is not a plainproof checkout (no substitutions.json or reports/); use --root')
|
|
24
|
+
return root
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sh(root, *cmd):
|
|
28
|
+
env = dict(os.environ)
|
|
29
|
+
env.setdefault('REPRO_DATA', str(root / 'data'))
|
|
30
|
+
print('+', ' '.join(str(c) for c in cmd), flush=True)
|
|
31
|
+
r = subprocess.run([str(c) for c in cmd], cwd=root, env=env)
|
|
32
|
+
if r.returncode:
|
|
33
|
+
raise SystemExit(r.returncode)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sha(p):
|
|
37
|
+
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv=None):
|
|
41
|
+
ap = argparse.ArgumentParser(prog='plainproof', description=__doc__.split('\n\n')[0])
|
|
42
|
+
ap.add_argument('--root', help='repository root (default: current directory)')
|
|
43
|
+
sub = ap.add_subparsers(dest='cmd', required=True)
|
|
44
|
+
f = sub.add_parser('fetch'); f.add_argument('what', nargs='?', default='all', choices=('bobcat', 'heldout', 'all'))
|
|
45
|
+
sub.add_parser('fit')
|
|
46
|
+
r = sub.add_parser('run'); r.add_argument('study', choices=('e0', 'e0b'))
|
|
47
|
+
sub.add_parser('figures')
|
|
48
|
+
sub.add_parser('verify')
|
|
49
|
+
a = ap.parse_args(argv)
|
|
50
|
+
root = find_root(a.root)
|
|
51
|
+
py = sys.executable
|
|
52
|
+
data = Path(os.environ.get('REPRO_DATA', root / 'data'))
|
|
53
|
+
if a.cmd == 'fetch':
|
|
54
|
+
if a.what in ('bobcat', 'all'):
|
|
55
|
+
sh(root, 'bash', 'scripts/bobcat/fetch_bobcat.sh')
|
|
56
|
+
if a.what in ('heldout', 'all'):
|
|
57
|
+
if not os.environ.get('ZENODO_URL'):
|
|
58
|
+
raise SystemExit('set ZENODO_URL to the Zenodo record file URL to fetch the held-out outputs')
|
|
59
|
+
sh(root, 'bash', 'scripts/fetch_heldout.sh')
|
|
60
|
+
elif a.cmd == 'fit':
|
|
61
|
+
sh(root, py, 'scripts/edm_v2_e0/fit_assist2009.py', data / 'edm-v2-e0' / 'item-fit')
|
|
62
|
+
elif a.cmd == 'run':
|
|
63
|
+
if a.study == 'e0':
|
|
64
|
+
out = data / 'edm-v2-e0' / 'heldout'
|
|
65
|
+
sh(root, py, 'scripts/edm_v2_e0/heldout.py', 'run', out)
|
|
66
|
+
sh(root, py, 'scripts/edm_v2_e0/heldout.py', 'summarize', out)
|
|
67
|
+
else:
|
|
68
|
+
out = data / 'edm-v2-e0b' / 'heldout'
|
|
69
|
+
sh(root, py, 'scripts/edm_v2_e0b/run.py', 'run', out)
|
|
70
|
+
sh(root, py, 'scripts/edm_v2_e0b/run.py', 'summarize', out)
|
|
71
|
+
elif a.cmd == 'figures':
|
|
72
|
+
s0, s1 = data / 'edm-v2-e0' / 'heldout' / 'summary.json', data / 'edm-v2-e0b' / 'heldout' / 'summary.json'
|
|
73
|
+
sh(root, py, 'scripts/edm_v2_e0/report.py', s0)
|
|
74
|
+
sh(root, 'bash', 'scripts/rscript.sh', 'scripts/edm_v2_e0/plot_error_vs_d.R', 'reports/edm-v2-e0/error-vs-d.csv',
|
|
75
|
+
'reports/edm-v2-e0/figures/error-vs-d.png', sha(s0))
|
|
76
|
+
sh(root, py, 'scripts/edm_v2_e0b/report.py', s1)
|
|
77
|
+
sh(root, 'bash', 'scripts/rscript.sh', 'scripts/edm_v2_e0b/plot_probe_auc.R', 'reports/edm-v2-e0b/probe-auc-vs-d.csv',
|
|
78
|
+
'reports/edm-v2-e0b/figures/probe-auc-vs-d.png', sha(s1))
|
|
79
|
+
elif a.cmd == 'verify':
|
|
80
|
+
sh(root, py, 'scripts/verify_frozen.py')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == '__main__':
|
|
84
|
+
main()
|
plainproof/fit.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Two-parameter logistic fit (c = 0) by marginal maximum likelihood (EM, Gauss-Hermite quadrature).
|
|
2
|
+
|
|
3
|
+
Used to anchor simulated item parameters in real responses (ASSIST2009 development learners only).
|
|
4
|
+
Missing responses are allowed. Guessing is not fitted: c is set per item in the simulator and stated
|
|
5
|
+
as an assumption, because sparse open-response data do not identify it.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dev_learner(user_id, salt='edm-v2-e0'):
|
|
13
|
+
"""Fixed development/held-out split by hashed learner ID (half each); never by outcomes."""
|
|
14
|
+
h = hashlib.sha256(f'{salt}:{user_id}'.encode()).digest()
|
|
15
|
+
return h[0] % 2 == 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fit_2pl(resp, n_quad=31, iters=200, tol=1e-6, prior_sd_loga=0.5):
|
|
19
|
+
"""resp: learners x items array of 0/1 with NaN for missing. Returns (a, b, log-likelihood history)."""
|
|
20
|
+
resp = np.asarray(resp, dtype=float)
|
|
21
|
+
obs = ~np.isnan(resp)
|
|
22
|
+
u = np.nan_to_num(resp)
|
|
23
|
+
x, w = np.polynomial.hermite_e.hermegauss(n_quad)
|
|
24
|
+
w = w / w.sum()
|
|
25
|
+
n_items = resp.shape[1]
|
|
26
|
+
a, b = np.ones(n_items), np.zeros(n_items)
|
|
27
|
+
hist = []
|
|
28
|
+
for _ in range(iters):
|
|
29
|
+
p = 1 / (1 + np.exp(-a[None, :] * (x[:, None] - b[None, :]))) # quad x items
|
|
30
|
+
lp, lq = np.log(np.clip(p, 1e-12, 1)), np.log(np.clip(1 - p, 1e-12, 1))
|
|
31
|
+
ll = (u * obs) @ lp.T + ((1 - u) * obs) @ lq.T # learners x quad
|
|
32
|
+
m = ll.max(axis=1, keepdims=True)
|
|
33
|
+
post = np.exp(ll - m) * w[None, :]
|
|
34
|
+
marg = post.sum(axis=1, keepdims=True)
|
|
35
|
+
hist.append(float((np.log(marg) + m).sum()))
|
|
36
|
+
post /= marg
|
|
37
|
+
n_k = post.T @ obs # expected attempts
|
|
38
|
+
r_k = post.T @ (u * obs) # expected corrects
|
|
39
|
+
for j in range(n_items): # Newton step per item
|
|
40
|
+
for _ in range(5):
|
|
41
|
+
pj = 1 / (1 + np.exp(-a[j] * (x - b[j])))
|
|
42
|
+
res = r_k[:, j] - n_k[:, j] * pj
|
|
43
|
+
ww = n_k[:, j] * pj * (1 - pj)
|
|
44
|
+
dz_da, dz_db = x - b[j], -a[j]
|
|
45
|
+
la = np.log(a[j])
|
|
46
|
+
g = np.array([(res * dz_da).sum() * a[j] - la / prior_sd_loga ** 2, (res * dz_db).sum()])
|
|
47
|
+
H = -np.array([[(ww * dz_da ** 2).sum() * a[j] ** 2 + 1 / prior_sd_loga ** 2,
|
|
48
|
+
(ww * dz_da * dz_db).sum() * a[j]],
|
|
49
|
+
[(ww * dz_da * dz_db).sum() * a[j], (ww * dz_db ** 2).sum()]])
|
|
50
|
+
step = np.linalg.solve(H, g)
|
|
51
|
+
la, b[j] = la - step[0], b[j] - step[1]
|
|
52
|
+
a[j] = float(np.clip(np.exp(la), 0.05, 6.0))
|
|
53
|
+
b[j] = float(np.clip(b[j], -6, 6))
|
|
54
|
+
if len(hist) > 1 and abs(hist[-1] - hist[-2]) < tol * abs(hist[-2]):
|
|
55
|
+
break
|
|
56
|
+
return a, b, hist
|
plainproof/irt.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Three-parameter logistic model with D = 1, as catIrt's brm: P = c + (1 - c) / (1 + exp(-a (theta - b)))."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def p3pl(theta, a, b, c):
|
|
6
|
+
"""Probability of a correct answer. Broadcasts over theta and item arrays."""
|
|
7
|
+
return c + (1.0 - c) / (1.0 + np.exp(-a * (theta - b)))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fisher_info(theta, a, b, c):
|
|
11
|
+
"""Expected Fisher information, catIrt FI.brm(type='expected'): p'(theta)^2 / (p q)."""
|
|
12
|
+
p = p3pl(theta, a, b, c)
|
|
13
|
+
e = np.exp(-a * (theta - b))
|
|
14
|
+
dp = (1.0 - c) * a * e / (1.0 + e) ** 2
|
|
15
|
+
return dp ** 2 / (p * (1.0 - p))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def loglik(u, theta, a, b, c):
|
|
19
|
+
"""Sum of Bernoulli log-likelihoods for responses u (0/1) at one theta."""
|
|
20
|
+
p = p3pl(theta, np.asarray(a), np.asarray(b), np.asarray(c))
|
|
21
|
+
u = np.asarray(u, dtype=float)
|
|
22
|
+
return float(np.sum(u * np.log(p) + (1.0 - u) * np.log(1.0 - p)))
|
plainproof/planted.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""E0b: planted hard probes (ruling 25 Oct 2026). New code; the frozen E0 modules are not changed.
|
|
2
|
+
|
|
3
|
+
Three synthetic probe items per learner, difficulty 2.5, 3.0 and 3.5, discrimination at the bank median, c = 0,
|
|
4
|
+
planted at combined positions 4, 9 and 14 of every method's path (positions count every item asked). Probes are
|
|
5
|
+
asked but unscored: their answers never enter SPRT, BOBCAT, the posterior, calibration or stopping.
|
|
6
|
+
|
|
7
|
+
Outsourcing of a probe uses the learner's own outsourcing model on the same dial d:
|
|
8
|
+
uniform probability d;
|
|
9
|
+
uncertainty min(1, s * g(p)), g(p) = 4 p (1 - p), with s the learner's scale solved on the follow-up bank alone
|
|
10
|
+
(as simulator.outsourcing_probs does), so the bank's share stays exactly d.
|
|
11
|
+
Probe answers come from their own random stream, so scored answers are unchanged by planting.
|
|
12
|
+
"""
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .irt import p3pl
|
|
16
|
+
from .simulator import g
|
|
17
|
+
|
|
18
|
+
PROBE_B = (2.5, 3.0, 3.5)
|
|
19
|
+
POSITIONS = (4, 9, 14)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scale(d, p_bank):
|
|
23
|
+
"""The uncertainty model's per-learner scale s on the bank alone (same bisection as outsourcing_probs)."""
|
|
24
|
+
w = g(np.asarray(p_bank, dtype=float))
|
|
25
|
+
lo, hi = 0.0, 1.0
|
|
26
|
+
while np.minimum(1.0, hi * w).mean() < d:
|
|
27
|
+
hi *= 2.0
|
|
28
|
+
for _ in range(200):
|
|
29
|
+
mid = (lo + hi) / 2.0
|
|
30
|
+
if np.minimum(1.0, mid * w).mean() < d:
|
|
31
|
+
lo = mid
|
|
32
|
+
else:
|
|
33
|
+
hi = mid
|
|
34
|
+
return hi
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def probe_outsourcing(model, d, p_probe, p_bank):
|
|
38
|
+
p_probe = np.asarray(p_probe, dtype=float)
|
|
39
|
+
if model == 'uniform' or d in (0.0, 1.0):
|
|
40
|
+
return np.full(p_probe.shape, float(d))
|
|
41
|
+
return np.minimum(1.0, scale(d, p_bank) * g(p_probe))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plant(theta, model, d, a_ai, a_probe, bank_a, bank_b, bank_c, rng):
|
|
45
|
+
"""One learner's three probe answers and true outsourced flags."""
|
|
46
|
+
b = np.array(PROBE_B)
|
|
47
|
+
p_own = p3pl(theta, a_probe, b, 0.0)
|
|
48
|
+
q = probe_outsourcing(model, d, p_own, p3pl(theta, bank_a, bank_b, bank_c))
|
|
49
|
+
out = rng.random(3) < q
|
|
50
|
+
correct = np.where(out, rng.random(3) < a_ai, rng.random(3) < p_own)
|
|
51
|
+
return correct.astype(int), out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def scored_before(k):
|
|
55
|
+
"""Number of scored items within the first k combined positions."""
|
|
56
|
+
return k - sum(1 for p in POSITIONS if p <= k)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def probes_before(k):
|
|
60
|
+
return sum(1 for p in POSITIONS if p <= k)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Third outsourcing model, exploratory (ruling 25 Oct 2026): give-up. The outsourcing weight rises as the learner's
|
|
64
|
+
# own chance on the item falls, peaking at p = 0: w(p) = 1 - p, scaled per learner so the mean share over the
|
|
65
|
+
# follow-up bank equals d, like the other two models. Defined here so the frozen simulator is not changed.
|
|
66
|
+
|
|
67
|
+
def w_giveup(p):
|
|
68
|
+
return 1.0 - np.asarray(p, dtype=float)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _scale_for(weights, d):
|
|
72
|
+
lo, hi = 0.0, 1.0
|
|
73
|
+
while np.minimum(1.0, hi * weights).mean() < d:
|
|
74
|
+
hi *= 2.0
|
|
75
|
+
for _ in range(200):
|
|
76
|
+
mid = (lo + hi) / 2.0
|
|
77
|
+
if np.minimum(1.0, mid * weights).mean() < d:
|
|
78
|
+
lo = mid
|
|
79
|
+
else:
|
|
80
|
+
hi = mid
|
|
81
|
+
return hi
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def giveup_probs(d, p_bank):
|
|
85
|
+
if d in (0.0, 1.0):
|
|
86
|
+
return np.full(np.shape(p_bank), float(d))
|
|
87
|
+
w = w_giveup(p_bank)
|
|
88
|
+
return np.minimum(1.0, _scale_for(w, d) * w)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def probe_outsourcing_any(model, d, p_probe, p_bank):
|
|
92
|
+
if model == 'giveup':
|
|
93
|
+
p_probe = np.asarray(p_probe, dtype=float)
|
|
94
|
+
if d in (0.0, 1.0):
|
|
95
|
+
return np.full(p_probe.shape, float(d))
|
|
96
|
+
return np.minimum(1.0, _scale_for(w_giveup(p_bank), d) * w_giveup(p_probe))
|
|
97
|
+
return probe_outsourcing(model, d, p_probe, p_bank)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plant_any(theta, model, d, a_ai, a_probe, bank_a, bank_b, bank_c, rng):
|
|
101
|
+
b = np.array(PROBE_B)
|
|
102
|
+
p_own = p3pl(theta, a_probe, b, 0.0)
|
|
103
|
+
q = probe_outsourcing_any(model, d, p_own, p3pl(theta, bank_a, bank_b, bank_c))
|
|
104
|
+
out = rng.random(3) < q
|
|
105
|
+
correct = np.where(out, rng.random(3) < a_ai, rng.random(3) < p_own)
|
|
106
|
+
return correct.astype(int), out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def make_world(bank, n, model, d, a_ai, seed, h_share=0.3, cut=0.0, margin=0.25):
|
|
110
|
+
"""A frozen simulator.World for 'uniform'/'uncertainty'; for 'giveup', a subclass whose respond() uses the
|
|
111
|
+
give-up probabilities with the same random draws and logging as the frozen respond()."""
|
|
112
|
+
from .simulator import World
|
|
113
|
+
if model != 'giveup':
|
|
114
|
+
return World(bank, n, cut=cut, margin=margin, h_share=h_share, a_ai=a_ai, model=model, d=d, seed=seed)
|
|
115
|
+
|
|
116
|
+
class GiveUpWorld(World):
|
|
117
|
+
def respond(self, learner_id, followup_index):
|
|
118
|
+
l = self.learners[learner_id]
|
|
119
|
+
j = self._fu[followup_index]
|
|
120
|
+
p_own = float(p3pl(l.theta, self.bank.a[j], self.bank.b[j], self.bank.c[j]))
|
|
121
|
+
if learner_id not in self._q:
|
|
122
|
+
fa, fb, fc = self.followup_bank()
|
|
123
|
+
self._q[learner_id] = giveup_probs(self.d, p3pl(l.theta, fa, fb, fc))
|
|
124
|
+
if self.rng.random() < self._q[learner_id][followup_index]:
|
|
125
|
+
u, outsourced = int(self.rng.random() < self.a_ai), True
|
|
126
|
+
else:
|
|
127
|
+
u, outsourced = int(self.rng.random() < p_own), False
|
|
128
|
+
self.log[learner_id].append(self._row(j, 'followup', u, outsourced))
|
|
129
|
+
return u
|
|
130
|
+
|
|
131
|
+
w = GiveUpWorld(bank, n, cut=cut, margin=margin, h_share=h_share, a_ai=a_ai, model='uniform', d=d, seed=seed)
|
|
132
|
+
w.model = 'giveup'
|
|
133
|
+
return w
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Reporting only: decisions never infer truth from assistance or confidence."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
def matched_coverage(records,coverage):
|
|
5
|
+
if not 0<coverage<=1 or not records:raise ValueError('coverage/population')
|
|
6
|
+
ids=[r['id'] for r in records]
|
|
7
|
+
if len(ids)!=len(set(ids)):raise ValueError('duplicate decision ID')
|
|
8
|
+
for r in records:
|
|
9
|
+
if not 0<=r['p_mastery']<=1 or r['truth'] not in (0,1):raise ValueError('invalid decision')
|
|
10
|
+
# Confidence and stable public ID only. Truth cannot affect the selected set.
|
|
11
|
+
ranked=sorted(records,key=lambda r:(-max(r['p_mastery'],1-r['p_mastery']),r['id']))
|
|
12
|
+
n=math.ceil(len(records)*coverage);chosen=ranked[:n]
|
|
13
|
+
fm=sum(r['p_mastery']>=.5 and r['truth']==0 for r in chosen)
|
|
14
|
+
fg=sum(r['p_mastery']<.5 and r['truth']==1 for r in chosen)
|
|
15
|
+
return dict(total=len(records),decisions=n,inconclusive=len(records)-n,coverage=n/len(records),
|
|
16
|
+
false_mastery=fm,false_gap=fg,error_given_decision=(fm+fg)/n,
|
|
17
|
+
false_mastery_all=fm/len(records),false_gap_all=fg/len(records),
|
|
18
|
+
selected_ids=[r['id'] for r in chosen])
|
|
19
|
+
|
|
20
|
+
def first_stop(history,threshold):
|
|
21
|
+
"""One target's actual first passage. Never selects the best realized endpoint."""
|
|
22
|
+
if not .5<threshold<=1:raise ValueError('confidence threshold')
|
|
23
|
+
previous=-1
|
|
24
|
+
for row in history:
|
|
25
|
+
if row['budget']<=previous or not 0<=row['p_mastery']<=1:raise ValueError('bad trajectory')
|
|
26
|
+
previous=row['budget']
|
|
27
|
+
if max(row['p_mastery'],1-row['p_mastery'])>=threshold:return row
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def crossing(budgets,errors,target):
|
|
31
|
+
if len(budgets)!=len(errors) or not budgets or any(a>=b for a,b in zip(budgets,budgets[1:])):raise ValueError('budget grid')
|
|
32
|
+
if not all(math.isfinite(x) for x in [*errors,target]):raise ValueError('nonfinite error')
|
|
33
|
+
return next((b for b,e in zip(budgets,errors) if e<=target),None)
|
plainproof/signature.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Secondary, descriptive: signatures of outsourcing in per-learner logs (ruling 25 Oct 2026).
|
|
2
|
+
|
|
3
|
+
Six per-learner statistics, each computed on the first k follow-ups of the learner's SPRT order (k = 5, 10, 15,
|
|
4
|
+
20), plus all of the learner's homework and transfer items. The direction in which outsourcing is expected to
|
|
5
|
+
move each statistic is fixed here, before any held-out data exists; AUC is P(statistic is further in that
|
|
6
|
+
direction for an outsourcing-cell learner than for an honest-cell learner), with ties counted half.
|
|
7
|
+
Separation line: AUC 0.75, fixed now. No selection among the six.
|
|
8
|
+
|
|
9
|
+
1 difficulty_correctness point-biserial correlation of item difficulty b with correctness. Honest answers
|
|
10
|
+
fall off with difficulty (negative); outsourced answers do not. Expected: higher.
|
|
11
|
+
2 lz standardised log-likelihood person fit (Drasgow et al. 1985) at the honest EAP
|
|
12
|
+
theta. Outsourcing misfits the item model. Expected: lower (statistic = -lz).
|
|
13
|
+
3 llr_outsourcing log likelihood of the answers under "honest at theta plus uniform outsourcing at d
|
|
14
|
+
with AI accuracy a" minus under "honest at theta", at the honest EAP theta.
|
|
15
|
+
Zero when d = 0. Expected: higher.
|
|
16
|
+
4 hw_minus_fu EAP theta from homework minus EAP theta from follow-ups. Follow-ups inflated by
|
|
17
|
+
outsourcing. Expected: lower (statistic = -(hw - fu)).
|
|
18
|
+
5 fu_minus_transfer EAP theta from follow-ups minus EAP theta from transfer items (always own answers).
|
|
19
|
+
Expected: higher.
|
|
20
|
+
6 sprt_confident_wrong share of the k SPRT steps at which calibrated confidence was at least 0.90 and the
|
|
21
|
+
implied decision disagreed with truth. Uses truth: a simulation-only check, not
|
|
22
|
+
something a deployed system can compute. Expected: higher.
|
|
23
|
+
7 probe_excess among the fixed probe items (the 10 hardest follow-ups) that the method actually asked
|
|
24
|
+
in its first k, observed hit rate minus the rate predicted at the learner's EAP theta
|
|
25
|
+
from those same k follow-ups. Computed per method on that method's own order, never
|
|
26
|
+
pooled; NaN when no probe was asked, and the NaN count is reported. Expected: higher.
|
|
27
|
+
Statistics 1 to 6 use the SPRT order; statistic 7 uses each method's order.
|
|
28
|
+
"""
|
|
29
|
+
import math
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from .irt import p3pl
|
|
34
|
+
|
|
35
|
+
CHECKPOINTS = (5, 10, 15, 20)
|
|
36
|
+
SEPARATION = 0.75
|
|
37
|
+
DIRECTION = dict(difficulty_correctness=+1, lz=-1, llr_outsourcing=+1, hw_minus_fu=-1, fu_minus_transfer=+1,
|
|
38
|
+
sprt_confident_wrong=+1, probe_excess=+1)
|
|
39
|
+
REQUIRED_LOG_FIELDS = ('item', 'difficulty', 'bank', 'correct', 'outsourced')
|
|
40
|
+
GRID = np.linspace(-4, 4, 161)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def eap(a, b, c, u):
|
|
44
|
+
if len(u) == 0:
|
|
45
|
+
return float('nan')
|
|
46
|
+
ll = -GRID ** 2 / 2
|
|
47
|
+
for aj, bj, cj, uj in zip(a, b, c, u):
|
|
48
|
+
p = p3pl(GRID, aj, bj, cj)
|
|
49
|
+
ll = ll + np.log(p if uj else 1 - p)
|
|
50
|
+
w = np.exp(ll - ll.max())
|
|
51
|
+
return float((GRID * w).sum() / w.sum())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lz(a, b, c, u, theta):
|
|
55
|
+
p = p3pl(theta, np.asarray(a), np.asarray(b), np.asarray(c))
|
|
56
|
+
u = np.asarray(u, dtype=float)
|
|
57
|
+
l0 = np.sum(u * np.log(p) + (1 - u) * np.log(1 - p))
|
|
58
|
+
e = np.sum(p * np.log(p) + (1 - p) * np.log(1 - p))
|
|
59
|
+
v = np.sum(p * (1 - p) * np.log(p / (1 - p)) ** 2)
|
|
60
|
+
return float((l0 - e) / math.sqrt(v)) if v > 0 else float('nan')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def llr_outsourcing(a, b, c, u, theta, d, a_ai):
|
|
64
|
+
p = p3pl(theta, np.asarray(a), np.asarray(b), np.asarray(c))
|
|
65
|
+
m = (1 - d) * p + d * a_ai
|
|
66
|
+
u = np.asarray(u, dtype=float)
|
|
67
|
+
return float(np.sum(u * (np.log(m) - np.log(p)) + (1 - u) * (np.log(1 - m) - np.log(1 - p))))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def point_biserial(x, u):
|
|
71
|
+
x, u = np.asarray(x, dtype=float), np.asarray(u, dtype=float)
|
|
72
|
+
if len(x) < 2 or x.std() == 0 or u.std() == 0:
|
|
73
|
+
return 0.0
|
|
74
|
+
return float(np.corrcoef(x, u)[0, 1])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def learner_stats(log, bank, sprt_order, k, d, a_ai, calibrated_conf, truth):
|
|
78
|
+
"""log: the world's rows for one learner. sprt_order: follow-up global item ids in SPRT order.
|
|
79
|
+
calibrated_conf: calibrated SPRT confidence per step (len >= k)."""
|
|
80
|
+
for r in log:
|
|
81
|
+
for f in REQUIRED_LOG_FIELDS:
|
|
82
|
+
if f not in r:
|
|
83
|
+
raise ValueError(f'log row lacks {f}')
|
|
84
|
+
fu = {r['item']: r for r in log if r['bank'] == 'followup'}
|
|
85
|
+
asked = [fu[j] for j in sprt_order[:k]]
|
|
86
|
+
hw = [r for r in log if r['bank'] == 'homework']
|
|
87
|
+
tr = [r for r in log if r['bank'] == 'transfer']
|
|
88
|
+
|
|
89
|
+
def arr(rows):
|
|
90
|
+
it = [r['item'] for r in rows]
|
|
91
|
+
return bank.a[it], bank.b[it], bank.c[it], [r['correct'] for r in rows]
|
|
92
|
+
|
|
93
|
+
fa, fb, fc, fu_u = arr(asked)
|
|
94
|
+
th_fu = eap(fa, fb, fc, fu_u)
|
|
95
|
+
th_hw = eap(*arr(hw))
|
|
96
|
+
th_tr = eap(*arr(tr))
|
|
97
|
+
conf = np.asarray(calibrated_conf[:k])
|
|
98
|
+
if truth in (0, 1):
|
|
99
|
+
wrong = [(p >= 0.9 and truth == 0) or (p <= 0.1 and truth == 1) for p in conf]
|
|
100
|
+
cw = float(np.mean(wrong))
|
|
101
|
+
else:
|
|
102
|
+
cw = float('nan')
|
|
103
|
+
return dict(difficulty_correctness=point_biserial(fb, fu_u), lz=lz(fa, fb, fc, fu_u, th_fu),
|
|
104
|
+
llr_outsourcing=llr_outsourcing(fa, fb, fc, fu_u, th_fu, d, a_ai),
|
|
105
|
+
hw_minus_fu=th_hw - th_fu, fu_minus_transfer=th_fu - th_tr, sprt_confident_wrong=cw,
|
|
106
|
+
outsourced_share=float(np.mean([r['outsourced'] for r in asked])) if asked else float('nan'))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def auc(honest, outsourcing, direction):
|
|
110
|
+
"""P(outsourcing value is further in `direction` than an honest value); ties count half. NaNs dropped."""
|
|
111
|
+
h = np.asarray([x for x in honest if np.isfinite(x)]) * direction
|
|
112
|
+
o = np.asarray([x for x in outsourcing if np.isfinite(x)]) * direction
|
|
113
|
+
if len(h) == 0 or len(o) == 0:
|
|
114
|
+
return float('nan')
|
|
115
|
+
allv = np.concatenate([h, o])
|
|
116
|
+
order = allv.argsort(kind='mergesort')
|
|
117
|
+
ranks = np.empty(len(allv))
|
|
118
|
+
sorted_v = allv[order]
|
|
119
|
+
i = 0
|
|
120
|
+
while i < len(allv): # average ranks for ties
|
|
121
|
+
j = i
|
|
122
|
+
while j + 1 < len(allv) and sorted_v[j + 1] == sorted_v[i]:
|
|
123
|
+
j += 1
|
|
124
|
+
ranks[order[i:j + 1]] = (i + j) / 2 + 1
|
|
125
|
+
i = j + 1
|
|
126
|
+
r_o = ranks[len(h):].sum()
|
|
127
|
+
return float((r_o - len(o) * (len(o) + 1) / 2) / (len(h) * len(o)))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def probe_excess(log, bank, order, k, probe_rows):
|
|
131
|
+
"""Statistic 7 for one learner and one method's order (follow-up global item ids, in the order asked)."""
|
|
132
|
+
fu = {r['item']: r for r in log if r['bank'] == 'followup'}
|
|
133
|
+
asked = [fu[j] for j in order[:k]]
|
|
134
|
+
it = [r['item'] for r in asked]
|
|
135
|
+
theta = eap(bank.a[it], bank.b[it], bank.c[it], [r['correct'] for r in asked])
|
|
136
|
+
hit = [r for r in asked if r['item'] in set(probe_rows)]
|
|
137
|
+
if not hit:
|
|
138
|
+
return float('nan')
|
|
139
|
+
pj = [r['item'] for r in hit]
|
|
140
|
+
predicted = float(np.mean(p3pl(theta, bank.a[pj], bank.b[pj], bank.c[pj])))
|
|
141
|
+
return float(np.mean([r['correct'] for r in hit])) - predicted
|
plainproof/simulator.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Learner worlds for the outsourcing study. Truth lives here and never reaches an arm.
|
|
2
|
+
|
|
3
|
+
One concept per world. Latent ability theta is drawn per learner; understanding is defined from theta alone
|
|
4
|
+
(understands / gap / borderline), independent of every estimator. Items belong to families; within each
|
|
5
|
+
family, homework, follow-up and transfer items come from disjoint banks, and some families are held out
|
|
6
|
+
for transfer only.
|
|
7
|
+
|
|
8
|
+
Answering:
|
|
9
|
+
own answer correct with the 3PL probability P(theta) (guessing via c; slips via the upper curve)
|
|
10
|
+
outsourced answer correct with probability a_ai, whatever the learner's understanding
|
|
11
|
+
Outsourcing models for follow-ups, on the same dial d in [0, 1]:
|
|
12
|
+
uniform every follow-up is outsourced with probability d
|
|
13
|
+
uncertainty item j is outsourced with probability min(1, s * g(p_j)), g(p) = 4 p (1 - p), which peaks at
|
|
14
|
+
p = 0.5; s is solved per learner so the mean over that learner's follow-up bank equals d.
|
|
15
|
+
Homework is outsourced at its own share h (uniform). Transfer answers are always the learner's own.
|
|
16
|
+
"""
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .irt import p3pl
|
|
22
|
+
|
|
23
|
+
OUTSOURCING_MODELS = ('uniform', 'uncertainty')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Bank:
|
|
28
|
+
a: np.ndarray
|
|
29
|
+
b: np.ndarray
|
|
30
|
+
c: np.ndarray
|
|
31
|
+
family: np.ndarray
|
|
32
|
+
role: np.ndarray # 'homework', 'followup', 'transfer'
|
|
33
|
+
|
|
34
|
+
def rows(self, role):
|
|
35
|
+
return np.flatnonzero(self.role == role)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_bank(a, b, c, family, held_out_families, rng, shares=(0.4, 0.4, 0.2)):
|
|
39
|
+
"""Assign roles. Held-out families are transfer-only; every other family is split three ways."""
|
|
40
|
+
a, b, c, family = (np.asarray(x) for x in (a, b, c, family))
|
|
41
|
+
role = np.empty(len(a), dtype=object)
|
|
42
|
+
for f in np.unique(family):
|
|
43
|
+
idx = np.flatnonzero(family == f)
|
|
44
|
+
if f in set(held_out_families):
|
|
45
|
+
role[idx] = 'transfer'
|
|
46
|
+
continue
|
|
47
|
+
idx = rng.permutation(idx)
|
|
48
|
+
n_hw = int(round(shares[0] * len(idx)))
|
|
49
|
+
n_fu = int(round(shares[1] * len(idx)))
|
|
50
|
+
role[idx[:n_hw]] = 'homework'
|
|
51
|
+
role[idx[n_hw:n_hw + n_fu]] = 'followup'
|
|
52
|
+
role[idx[n_hw + n_fu:]] = 'transfer'
|
|
53
|
+
return Bank(a.astype(float), b.astype(float), c.astype(float), family, role)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def understanding(theta, cut, margin):
|
|
57
|
+
"""1 understands, 0 gap, -1 borderline (kept as its own category, never forced to a side)."""
|
|
58
|
+
theta = np.asarray(theta)
|
|
59
|
+
out = np.full(theta.shape, -1)
|
|
60
|
+
out[theta >= cut + margin] = 1
|
|
61
|
+
out[theta <= cut - margin] = 0
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def g(p):
|
|
66
|
+
return 4.0 * p * (1.0 - p)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def outsourcing_probs(model, d, p_own):
|
|
70
|
+
"""Per-item outsourcing probabilities for one learner's follow-up bank. Their mean equals d."""
|
|
71
|
+
p_own = np.asarray(p_own, dtype=float)
|
|
72
|
+
if not 0 <= d <= 1:
|
|
73
|
+
raise ValueError('d')
|
|
74
|
+
if model == 'uniform':
|
|
75
|
+
return np.full(p_own.shape, float(d))
|
|
76
|
+
if model != 'uncertainty':
|
|
77
|
+
raise ValueError('outsourcing model')
|
|
78
|
+
if d == 0:
|
|
79
|
+
return np.zeros(p_own.shape)
|
|
80
|
+
if d == 1:
|
|
81
|
+
return np.ones(p_own.shape)
|
|
82
|
+
w = g(p_own)
|
|
83
|
+
if not np.any(w > 0):
|
|
84
|
+
raise ValueError('no item near p = 0.5 can carry the share')
|
|
85
|
+
lo, hi = 0.0, 1.0
|
|
86
|
+
while np.minimum(1.0, hi * w).mean() < d:
|
|
87
|
+
hi *= 2.0
|
|
88
|
+
if hi > 1e12:
|
|
89
|
+
raise ValueError('share d not reachable')
|
|
90
|
+
for _ in range(200):
|
|
91
|
+
mid = (lo + hi) / 2.0
|
|
92
|
+
if np.minimum(1.0, mid * w).mean() < d:
|
|
93
|
+
lo = mid
|
|
94
|
+
else:
|
|
95
|
+
hi = mid
|
|
96
|
+
return np.minimum(1.0, hi * w)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Learner:
|
|
101
|
+
id: int
|
|
102
|
+
theta: float
|
|
103
|
+
truth: int # 1 understands, 0 gap, -1 borderline
|
|
104
|
+
homework_score: float
|
|
105
|
+
transfer_score: float
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class World:
|
|
109
|
+
"""One simulated population. Arms get only respond(); truth and theta stay here."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, bank, n_learners, cut, margin, h_share, a_ai, model, d, seed,
|
|
112
|
+
theta_mean=0.0, theta_sd=1.0):
|
|
113
|
+
if model not in OUTSOURCING_MODELS:
|
|
114
|
+
raise ValueError('outsourcing model')
|
|
115
|
+
if not (0 <= h_share <= 1 and 0 <= a_ai <= 1 and 0 <= d <= 1):
|
|
116
|
+
raise ValueError('share/accuracy')
|
|
117
|
+
self.bank, self.cut, self.margin = bank, cut, margin
|
|
118
|
+
self.a_ai, self.model, self.d = a_ai, model, d
|
|
119
|
+
self.rng = np.random.default_rng(seed)
|
|
120
|
+
theta = self.rng.normal(theta_mean, theta_sd, n_learners)
|
|
121
|
+
truth = understanding(theta, cut, margin)
|
|
122
|
+
hw, tr = bank.rows('homework'), bank.rows('transfer')
|
|
123
|
+
self.learners = []
|
|
124
|
+
self.log = {} # truth-side per-learner log; never passed to an arm (same RNG draws as before)
|
|
125
|
+
for i, t in enumerate(theta):
|
|
126
|
+
p_hw = p3pl(t, bank.a[hw], bank.b[hw], bank.c[hw])
|
|
127
|
+
out = self.rng.random(len(hw)) < h_share
|
|
128
|
+
correct = np.where(out, self.rng.random(len(hw)) < a_ai, self.rng.random(len(hw)) < p_hw)
|
|
129
|
+
p_tr = p3pl(t, bank.a[tr], bank.b[tr], bank.c[tr])
|
|
130
|
+
tr_correct = self.rng.random(len(tr)) < p_tr
|
|
131
|
+
transfer = tr_correct.mean() if len(tr) else float('nan')
|
|
132
|
+
hw_score = float(correct.mean()) if len(hw) else float('nan')
|
|
133
|
+
self.learners.append(Learner(i, float(t), int(truth[i]), hw_score, float(transfer)))
|
|
134
|
+
self.log[i] = ([self._row(j, 'homework', c, o) for j, c, o in zip(hw, correct, out)]
|
|
135
|
+
+ [self._row(j, 'transfer', c, False) for j, c in zip(tr, tr_correct)])
|
|
136
|
+
self._fu = bank.rows('followup')
|
|
137
|
+
self._q = {}
|
|
138
|
+
|
|
139
|
+
def _row(self, j, bank_name, correct, outsourced):
|
|
140
|
+
return dict(item=int(j), difficulty=float(self.bank.b[j]), discrimination=float(self.bank.a[j]),
|
|
141
|
+
bank=bank_name, family=int(self.bank.family[j]), correct=int(correct), outsourced=bool(outsourced))
|
|
142
|
+
|
|
143
|
+
def cohort(self, min_homework):
|
|
144
|
+
"""'Comparable homework success': learners at or above a homework score, selected without truth."""
|
|
145
|
+
return [l for l in self.learners if l.homework_score >= min_homework]
|
|
146
|
+
|
|
147
|
+
def followup_bank(self):
|
|
148
|
+
"""Item parameters an arm may see: follow-up items only, never truth, theta or the generator."""
|
|
149
|
+
f = self._fu
|
|
150
|
+
return self.bank.a[f].copy(), self.bank.b[f].copy(), self.bank.c[f].copy()
|
|
151
|
+
|
|
152
|
+
def respond(self, learner_id, followup_index):
|
|
153
|
+
"""One follow-up answer (0/1). followup_index indexes followup_bank()."""
|
|
154
|
+
l = self.learners[learner_id]
|
|
155
|
+
j = self._fu[followup_index]
|
|
156
|
+
p_own = float(p3pl(l.theta, self.bank.a[j], self.bank.b[j], self.bank.c[j]))
|
|
157
|
+
if learner_id not in self._q:
|
|
158
|
+
fa, fb, fc = self.followup_bank()
|
|
159
|
+
self._q[learner_id] = outsourcing_probs(self.model, self.d, p3pl(l.theta, fa, fb, fc))
|
|
160
|
+
if self.rng.random() < self._q[learner_id][followup_index]:
|
|
161
|
+
u, outsourced = int(self.rng.random() < self.a_ai), True
|
|
162
|
+
else:
|
|
163
|
+
u, outsourced = int(self.rng.random() < p_own), False
|
|
164
|
+
self.log[learner_id].append(self._row(j, 'followup', u, outsourced))
|
|
165
|
+
return u
|
plainproof/sprt.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Wald SPRT classification at one cut score, with item selection by Fisher information at the cut.
|
|
2
|
+
|
|
3
|
+
Matches catIrt 0.5.1 (termSPRT, itChoose with select='UW-FI', at='bounds', one bound):
|
|
4
|
+
log ratio = logLik(theta = cut + delta) - logLik(theta = cut - delta)
|
|
5
|
+
upper = log((1 - beta) / alpha) -> master (1); lower = log(beta / (1 - alpha)) -> non-master (0).
|
|
6
|
+
Next item: largest expected information at the cut among unused items; ties go to the earliest bank row
|
|
7
|
+
(R's order(decreasing=TRUE) is stable).
|
|
8
|
+
Truncation at n_max is its own outcome ('truncated'), never a classification success. The theta-vs-cut
|
|
9
|
+
fallback that catIrt applies at truncation is kept separately (fallback_decision) for replay only.
|
|
10
|
+
"""
|
|
11
|
+
import math
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .irt import fisher_info, loglik
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SPRTConfig:
|
|
21
|
+
cut: float
|
|
22
|
+
delta: float = 0.1
|
|
23
|
+
alpha: float = 0.05
|
|
24
|
+
beta: float = 0.05
|
|
25
|
+
n_max: int = 50
|
|
26
|
+
n_min: int = 1
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
if not (0 < self.alpha < 1 and 0 < self.beta < 1 and self.alpha + self.beta < 1):
|
|
30
|
+
raise ValueError('alpha/beta')
|
|
31
|
+
if self.delta <= 0 or self.n_max < 1 or not 1 <= self.n_min <= self.n_max:
|
|
32
|
+
raise ValueError('delta/n')
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def upper(self):
|
|
36
|
+
return math.log((1 - self.beta) / self.alpha)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def lower(self):
|
|
40
|
+
return math.log(self.beta / (1 - self.alpha))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SPRTState:
|
|
45
|
+
items: list = field(default_factory=list)
|
|
46
|
+
responses: list = field(default_factory=list)
|
|
47
|
+
log_ratios: list = field(default_factory=list)
|
|
48
|
+
decision: object = None # 1 master, 0 non-master, 'truncated', or None while running
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SPRTArm:
|
|
52
|
+
"""One learner's sequential test over a fixed item bank (rows of a, b, c)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, a, b, c, config: SPRTConfig):
|
|
55
|
+
self.a, self.b, self.c = (np.asarray(x, dtype=float) for x in (a, b, c))
|
|
56
|
+
if not (self.a.shape == self.b.shape == self.c.shape) or self.a.ndim != 1:
|
|
57
|
+
raise ValueError('bank shape')
|
|
58
|
+
self.cfg = config
|
|
59
|
+
self.info_at_cut = fisher_info(config.cut, self.a, self.b, self.c)
|
|
60
|
+
self.state = SPRTState()
|
|
61
|
+
|
|
62
|
+
def next_item(self):
|
|
63
|
+
if self.state.decision is not None:
|
|
64
|
+
raise RuntimeError('test already ended')
|
|
65
|
+
return self.next_item_ignoring_decision()
|
|
66
|
+
|
|
67
|
+
def next_item_ignoring_decision(self):
|
|
68
|
+
used = set(self.state.items)
|
|
69
|
+
best, best_info = None, -math.inf
|
|
70
|
+
for j, v in enumerate(self.info_at_cut):
|
|
71
|
+
if j not in used and v > best_info:
|
|
72
|
+
best, best_info = j, v
|
|
73
|
+
if best is None:
|
|
74
|
+
raise RuntimeError('bank exhausted')
|
|
75
|
+
return best
|
|
76
|
+
|
|
77
|
+
def log_ratio(self):
|
|
78
|
+
it = self.state.items
|
|
79
|
+
u = self.state.responses
|
|
80
|
+
a, b, c = self.a[it], self.b[it], self.c[it]
|
|
81
|
+
return (loglik(u, self.cfg.cut + self.cfg.delta, a, b, c)
|
|
82
|
+
- loglik(u, self.cfg.cut - self.cfg.delta, a, b, c))
|
|
83
|
+
|
|
84
|
+
def answer(self, item, response):
|
|
85
|
+
if response not in (0, 1):
|
|
86
|
+
raise ValueError('response must be 0 or 1')
|
|
87
|
+
s = self.state
|
|
88
|
+
s.items.append(int(item))
|
|
89
|
+
s.responses.append(int(response))
|
|
90
|
+
lr = self.log_ratio()
|
|
91
|
+
s.log_ratios.append(lr)
|
|
92
|
+
n = len(s.items)
|
|
93
|
+
if n >= self.cfg.n_min:
|
|
94
|
+
if lr >= self.cfg.upper:
|
|
95
|
+
s.decision = 1
|
|
96
|
+
elif lr <= self.cfg.lower:
|
|
97
|
+
s.decision = 0
|
|
98
|
+
if s.decision is None and (n >= self.cfg.n_max or n >= len(self.a)):
|
|
99
|
+
s.decision = 'truncated'
|
|
100
|
+
return s.decision
|
|
101
|
+
|
|
102
|
+
def p_mastery(self):
|
|
103
|
+
"""Reporting score for matched-coverage ranking: logistic of the log ratio
|
|
104
|
+
(posterior of 'cut + delta' against 'cut - delta' at equal prior). Not a calibrated probability."""
|
|
105
|
+
lr = self.state.log_ratios[-1] if self.state.log_ratios else 0.0
|
|
106
|
+
return 1.0 / (1.0 + math.exp(-lr))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_sprt(a, b, c, config, respond):
|
|
110
|
+
"""Run one test. respond(item_index) -> 0/1. Returns the final state."""
|
|
111
|
+
arm = SPRTArm(a, b, c, config)
|
|
112
|
+
while arm.state.decision is None:
|
|
113
|
+
j = arm.next_item()
|
|
114
|
+
arm.answer(j, respond(j))
|
|
115
|
+
return arm
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_sprt_full(a, b, c, config, respond):
|
|
119
|
+
"""Run the fixed SPRT order to config.n_max without stopping (ruling 25 Oct 2026).
|
|
120
|
+
|
|
121
|
+
Returns (items, responses, log_ratios, native_stop, native_decision): native_stop is the first step at which
|
|
122
|
+
the boundary fired (None if never within n_max), native_decision its classification (1/0) or 'truncated'.
|
|
123
|
+
The same items, in the same order, as run_sprt up to the native stop.
|
|
124
|
+
"""
|
|
125
|
+
arm = SPRTArm(a, b, c, config)
|
|
126
|
+
native_stop, native_decision = None, 'truncated'
|
|
127
|
+
for _ in range(min(config.n_max, len(arm.a))):
|
|
128
|
+
j = arm.next_item_ignoring_decision()
|
|
129
|
+
arm.state.items.append(int(j))
|
|
130
|
+
arm.state.responses.append(int(respond(j)))
|
|
131
|
+
lr = arm.log_ratio()
|
|
132
|
+
arm.state.log_ratios.append(lr)
|
|
133
|
+
n = len(arm.state.items)
|
|
134
|
+
if native_stop is None and n >= config.n_min and (lr >= config.upper or lr <= config.lower):
|
|
135
|
+
native_stop, native_decision = n, (1 if lr >= config.upper else 0)
|
|
136
|
+
return arm.state.items, arm.state.responses, arm.state.log_ratios, native_stop, native_decision
|
plainproof/stopping.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Expected SPRT stopping time when every item is identical (one fixed Bernoulli coin per response).
|
|
2
|
+
|
|
3
|
+
With uniform outsourcing share d and AI accuracy a_ai, each follow-up is correct with probability
|
|
4
|
+
p_true = (1 - d) * P(theta) + d * a_ai,
|
|
5
|
+
independently, while the SPRT still scores answers with the item model at cut +/- delta. The log ratio after
|
|
6
|
+
k correct of n answers is k * w1 + (n - k) * w0, so the walk lives on (n, k) and the exact law of the stopping
|
|
7
|
+
time, truncation included, is a finite-horizon calculation. Wald's approximation (no overshoot, no
|
|
8
|
+
truncation) is reported beside it for reference; it is not used as the test truth.
|
|
9
|
+
"""
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy.optimize import brentq
|
|
14
|
+
|
|
15
|
+
from .irt import p3pl
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def increments(a, b, c, cut, delta):
|
|
19
|
+
p_hi, p_lo = p3pl(cut + delta, a, b, c), p3pl(cut - delta, a, b, c)
|
|
20
|
+
return math.log(p_hi / p_lo), math.log((1 - p_hi) / (1 - p_lo)) # (correct, incorrect)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def p_correct(theta, a, b, c, d, a_ai):
|
|
24
|
+
if not (0 <= d <= 1 and 0 <= a_ai <= 1):
|
|
25
|
+
raise ValueError('d/a_ai')
|
|
26
|
+
return (1 - d) * float(p3pl(theta, a, b, c)) + d * a_ai
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def exact(p_true, w1, w0, upper, lower, n_max, n_min=1):
|
|
30
|
+
"""Exact stopping law. Returns expected length and the probabilities of each outcome."""
|
|
31
|
+
alive = np.zeros(n_max + 1) # alive[k]: probability of k correct so far and not yet stopped
|
|
32
|
+
alive[0] = 1.0
|
|
33
|
+
e_n = 0.0
|
|
34
|
+
p_master = p_non = 0.0
|
|
35
|
+
for n in range(1, n_max + 1):
|
|
36
|
+
nxt = np.zeros(n_max + 1)
|
|
37
|
+
nxt[1:n + 1] += alive[:n] * p_true
|
|
38
|
+
nxt[0:n] += alive[:n] * (1 - p_true)
|
|
39
|
+
k = np.arange(n + 1)
|
|
40
|
+
lr = k * w1 + (n - k) * w0
|
|
41
|
+
if n >= n_min:
|
|
42
|
+
up = (lr >= upper) & (nxt[:n + 1] > 0)
|
|
43
|
+
dn = (lr <= lower) & (nxt[:n + 1] > 0)
|
|
44
|
+
pm, pn = nxt[:n + 1][up].sum(), nxt[:n + 1][dn].sum()
|
|
45
|
+
p_master += pm
|
|
46
|
+
p_non += pn
|
|
47
|
+
e_n += n * (pm + pn)
|
|
48
|
+
nxt[:n + 1][up | dn] = 0.0
|
|
49
|
+
alive = nxt
|
|
50
|
+
p_trunc = alive.sum()
|
|
51
|
+
e_n += n_max * p_trunc
|
|
52
|
+
return dict(expected_n=e_n, p_master=p_master, p_non_master=p_non, p_truncated=p_trunc)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def wald(p_true, w1, w0, upper, lower):
|
|
56
|
+
"""Wald's approximation without truncation: P(master) from the OC function, E[N] from Wald's identity."""
|
|
57
|
+
mu = p_true * w1 + (1 - p_true) * w0
|
|
58
|
+
A, B = upper, lower
|
|
59
|
+
if abs(mu) < 1e-12:
|
|
60
|
+
var = p_true * w1 ** 2 + (1 - p_true) * w0 ** 2
|
|
61
|
+
return dict(expected_n=-A * B / var, p_master=-B / (A - B))
|
|
62
|
+
f = lambda h: p_true * math.exp(h * w1) + (1 - p_true) * math.exp(h * w0) - 1
|
|
63
|
+
# Non-zero root h of E[exp(h Z)] = 1 has the sign opposite to mu.
|
|
64
|
+
lo, hi = (1e-9, 1.0) if mu < 0 else (-1.0, -1e-9)
|
|
65
|
+
while f(hi if mu < 0 else lo) <= 0:
|
|
66
|
+
if mu < 0:
|
|
67
|
+
hi *= 2
|
|
68
|
+
else:
|
|
69
|
+
lo *= 2
|
|
70
|
+
h = brentq(f, lo, hi)
|
|
71
|
+
p_master = (1 - math.exp(h * B)) / (math.exp(h * A) - math.exp(h * B))
|
|
72
|
+
return dict(expected_n=(p_master * A + (1 - p_master) * B) / mu, p_master=p_master)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plainproof
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reproduction tool for the paper 'How many follow-up questions distinguish genuine understanding from apparent mastery?'. Not a general library. Its sealed checks can stop a run on purpose.
|
|
5
|
+
Author: Vishnu Vettrivel
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: numpy>=2.0
|
|
11
|
+
Requires-Dist: scipy>=1.13
|
|
12
|
+
Provides-Extra: run
|
|
13
|
+
Requires-Dist: scikit-learn>=1.5; extra == "run"
|
|
14
|
+
Requires-Dist: torch>=2.4; extra == "run"
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-xdist>=3.5; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# plainproof
|
|
21
|
+
|
|
22
|
+
Reproduction package for **"How many follow-up questions distinguish genuine understanding from apparent mastery?"** (EDM 2027 submission, simulation study). It asks how many adaptive follow-up questions a classification test needs to tell genuine understanding from apparent mastery at a calibrated confidence target, and how that changes as a learner outsources a share *d* of the follow-ups to an AI. A second, sealed study (E0b) tests whether planted hard probe items separate outsourcing learners from honest ones.
|
|
23
|
+
|
|
24
|
+
Everything here is CPU-only and needs no paid service. Code: MIT (`LICENSE`). Docs, figures and tables: CC BY 4.0 (`LICENSE-docs.md`).
|
|
25
|
+
|
|
26
|
+
## From clone to the figure (about 10 minutes)
|
|
27
|
+
|
|
28
|
+
The `plainproof` command runs each step (`plainproof --help`); the raw script calls below are what it runs. The local path inside the sealed `scripts/edm_v2_e0/r_env.sh` is part of the frozen record; use the `scripts/rscript.sh` wrapper, which `plainproof figures` calls.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
git clone <this repository> && cd plainproof
|
|
32
|
+
python3.11 -m venv .venv && . .venv/bin/activate && pip install -e '.[test,run]' # or: pip install -r requirements.txt
|
|
33
|
+
|
|
34
|
+
# 1. Tests (43): SPRT against catIrt 0.5.1's saved steps, exact stopping time, simulator, signatures, planted probes
|
|
35
|
+
python -m pytest -n auto -q tests
|
|
36
|
+
|
|
37
|
+
# 2. Check that the code here is the sealed code (undoes the documented path substitutions, then compares sha256)
|
|
38
|
+
plainproof verify
|
|
39
|
+
|
|
40
|
+
# 3. Fetch the sealed held-out outputs from Zenodo (DOI: to be assigned) and check their hashes
|
|
41
|
+
ZENODO_URL=<record file URL> plainproof fetch heldout
|
|
42
|
+
|
|
43
|
+
# 4. Rebuild the tables and figures from the held-out summaries (needs R 4.x for the plots; base graphics only)
|
|
44
|
+
python scripts/edm_v2_e0/report.py data/edm-v2-e0/heldout/summary.json
|
|
45
|
+
scripts/rscript.sh scripts/edm_v2_e0/plot_error_vs_d.R reports/edm-v2-e0/error-vs-d.csv reports/edm-v2-e0/figures/error-vs-d.png aff9a8c49d48821c0e9343cd058c4e0e2f1659ffabcaa719824412f3b14abc90
|
|
46
|
+
python scripts/edm_v2_e0b/report.py data/edm-v2-e0b/heldout/summary.json
|
|
47
|
+
scripts/rscript.sh scripts/edm_v2_e0b/plot_probe_auc.R reports/edm-v2-e0b/probe-auc-vs-d.csv reports/edm-v2-e0b/figures/probe-auc-vs-d.png 617be427feac24df2e342da00d34e91c78d5de91592cee390ef3516ae9e2ff3f
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The figures land in `reports/edm-v2-e0/figures/` and `reports/edm-v2-e0b/figures/`; the committed copies are the same files.
|
|
51
|
+
|
|
52
|
+
## Full rerun (about 4 hours of CPU)
|
|
53
|
+
|
|
54
|
+
Only if you want to regenerate everything, including the held-out outputs.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
# a. BOBCAT (MIT) at the pinned commit, with the preprocessed ASSISTments 2009-2010 file; the data's own terms forbid
|
|
58
|
+
# passing it on, so you fetch it from its source and accept them yourself
|
|
59
|
+
scripts/bobcat/fetch_bobcat.sh
|
|
60
|
+
# b. Item fit on the development half of ASSISTments learners (the output is checked against the receipt's hash)
|
|
61
|
+
python scripts/edm_v2_e0/fit_assist2009.py data/edm-v2-e0/item-fit
|
|
62
|
+
# c. Retrain the BOBCAT checkpoint (about 2 hours; author defaults, fold 1, seed 221), then compare it with the original
|
|
63
|
+
python scripts/bobcat/retrain.py && python scripts/bobcat/check_checkpoint.py
|
|
64
|
+
# d. catIrt fixture, development stages, held-out runs (each refuses to overwrite an existing cell)
|
|
65
|
+
scripts/rscript.sh scripts/edm_v2_e0/catirt_sprt_steps.R tests/fixtures/edm_v2_catirt_sprt
|
|
66
|
+
python scripts/edm_v2_e0/heldout.py run data/edm-v2-e0/heldout && python scripts/edm_v2_e0/heldout.py summarize data/edm-v2-e0/heldout
|
|
67
|
+
python scripts/edm_v2_e0b/run.py run data/edm-v2-e0b/heldout && python scripts/edm_v2_e0b/run.py summarize data/edm-v2-e0b/heldout
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Two caveats, stated plainly. The sealed code checks the item fit's sha256, so a different numpy can stop step b; and it asserts that the BOBCAT checkpoint's best epoch is 105, so a retrain that is not bit-identical can stop step d. In either case, use the Zenodo outputs. The development stages (`study.py tune|calibrate|calibrate_sprt_full|pilot`) are included for the record; their outputs (synthetic learners only) are in `data/edm-v2-e0/study/`.
|
|
71
|
+
|
|
72
|
+
## Fingerprints (sha256)
|
|
73
|
+
|
|
74
|
+
| What | sha256 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| E0 sealed freeze `reports/edm-v2-e0/freeze.json` | `a0f8ce404f2b5f7e671e2503a33b4554040e80714f6053553507e0972acdbcdc` |
|
|
77
|
+
| E0b sealed freeze `reports/edm-v2-e0b/freeze.json` | `3fa0b8d457d813f59abd5255f9303e44deb2119ee79d6a9b92439a6c1cbf58ed` |
|
|
78
|
+
| E0 held-out summary (Zenodo, `e0-heldout.tar` → `heldout/summary.json`) | `aff9a8c49d48821c0e9343cd058c4e0e2f1659ffabcaa719824412f3b14abc90` |
|
|
79
|
+
| E0b held-out summary (Zenodo, `e0b-heldout.tar` → `heldout/summary.json`) | `617be427feac24df2e342da00d34e91c78d5de91592cee390ef3516ae9e2ff3f` |
|
|
80
|
+
| Zenodo `e0-heldout.tar` | `bd06a11eb7163da102cad6ea0df12ea79e52ff94aab1dc35d28ec3910af2be22` |
|
|
81
|
+
| Zenodo `e0b-heldout.tar` | `68219f228c41da972fe4c8f92c218e01f85f08eed864185f237cecda05314bbf` |
|
|
82
|
+
| ASSISTments 2009 file at BOBCAT e6b6245e | `c07ca9fa0f08207b06488cec527725938fe96e81e3c1762839e1ad0b84f974cc` |
|
|
83
|
+
| 2PL item fit (regenerated in step b) | `12a3b167cd9c9b78b3c3c7faf71b2e464b0943a454f6418faf0f99b1114cb7fc` |
|
|
84
|
+
| catIrt 0.5.1 CRAN binary used for the fixture | `f5418f447e91d184791e668bd2a135ea5da40c981d4a76826018295b6ea9f079` |
|
|
85
|
+
|
|
86
|
+
## What is in here
|
|
87
|
+
|
|
88
|
+
- `src/plainproof/`: item model, SPRT arm, exact stopping time, simulator (uniform and uncertainty-triggered outsourcing), item fit, signatures, planted probes (with the give-up model), matched-coverage scoring.
|
|
89
|
+
- `scripts/edm_v2_e0/`, `scripts/edm_v2_e0b/`: the runners, report builders and R plots. `scripts/published_cat_reference_v1/`: the BOBCAT loading code the runners call.
|
|
90
|
+
- `reports/`: both sealed freezes, receipts, figures and tables. `docs/`: the E0 protocol and the pre-registration log (verbatim extract).
|
|
91
|
+
- `substitutions.json`: the only differences between this code and the sealed originals (package name, file locations, data paths). `scripts/verify_frozen.py` undoes them and checks every hash. `scripts/edm_v2_e0/r_env.sh` is kept verbatim as the record of the original R runtime; use `scripts/rscript.sh` instead.
|
|
92
|
+
|
|
93
|
+
## Recorded departures
|
|
94
|
+
|
|
95
|
+
All are in `docs/PREREG_EDM_V2.md`: the SPRT tuning rule was revised after its first development pass (labelled post hoc); SPRT's calibration map was refitted once on full paths before the freeze; the E0 held-out run exceeded its 2 GiB memory cap (no result depends on it; the runner now enforces the cap); E0b was sealed before its pilot table had been reviewed (no result affected).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
plainproof/__init__.py,sha256=IS3b5iDYXrJDFQpyYxD9uV59r2N81UrFMsnse3fB8GY,198
|
|
2
|
+
plainproof/bank_from_fit.py,sha256=358IWp85LgIC6zG0AjwN22U1iR3lWhYDsuLDVNGHYVc,1836
|
|
3
|
+
plainproof/cli.py,sha256=9-8yhSdmh2pOmBZu1yCVqooEmQUvB3EY6pEFgq45ZgM,3966
|
|
4
|
+
plainproof/fit.py,sha256=lDUAW-UwSUbGCv8M-uCCwQaVC_Bv4xu345tBA4e5aHE,2850
|
|
5
|
+
plainproof/irt.py,sha256=DSMeUFC8XAoC9MWyfPwk2VXUB5s1DwbgZ7oPN976MQs,852
|
|
6
|
+
plainproof/planted.py,sha256=LoDNUDFb8c-vpynexEJSdwxlD2nbHUMPhK_xNXVRuHg,5272
|
|
7
|
+
plainproof/reliability.py,sha256=gW4agB4FuuF3irkh6xiE-G8HJCh1JTnV9ZeCWGSjJys,1899
|
|
8
|
+
plainproof/signature.py,sha256=YVLNsb3r12Q4WawhMRYNUNCpwhplaFgids8GMbxKUAg,7033
|
|
9
|
+
plainproof/simulator.py,sha256=hWSUnlwupC6rL7dQKLqCtwSFl0d8iETev9mE2cbQZAw,7065
|
|
10
|
+
plainproof/sprt.py,sha256=MSna0x6hGDBlGuRzhw0M6Az4Mh5IpG218KEORArGXi4,5337
|
|
11
|
+
plainproof/stopping.py,sha256=XHgQt-YnvyHFJ10o8VWg_gTMqQ2YFEY1BD2p5KbsFZ8,3014
|
|
12
|
+
plainproof-0.1.0.dist-info/licenses/LICENSE,sha256=5hECxLv0f1ZSjmzY2K12w-0abXZZjpaxjSeHa5gvAeM,1073
|
|
13
|
+
plainproof-0.1.0.dist-info/METADATA,sha256=Wo6oexmbf6HJB8vufYTJ-_7eVdKlQ4RAfhHLOY2jFgQ,7112
|
|
14
|
+
plainproof-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
15
|
+
plainproof-0.1.0.dist-info/entry_points.txt,sha256=d2viDRssQqNdKLcwArfQN-oB-KaL5npxs7pxT2VVYWc,51
|
|
16
|
+
plainproof-0.1.0.dist-info/top_level.txt,sha256=rChE4Zof7WNLGxbWIjQl5znA_v6sDKntwCqTgt3p3wM,11
|
|
17
|
+
plainproof-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vishnu Vettrivel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plainproof
|