diff --git a/algorithmic/problems/59/interactor.cpp b/algorithmic/problems/59/interactor.cpp index 4ebcf47e8..7a85b26f5 100644 --- a/algorithmic/problems/59/interactor.cpp +++ b/algorithmic/problems/59/interactor.cpp @@ -9,16 +9,23 @@ int main(int argc, char* argv[]) { registerInteraction(argc, argv); int n = inf.readInt(1, 30000, "n"); + // The hidden array comes from a per-test secret seed in the answer file, + // which the contestant never sees. Unseeded testlib rnd is one fixed stream + // for every test, so a program could replay it without queries (#208). + const unsigned long long seed = ans.readLong(0LL, LLONG_MAX, "seed"); cout << n << endl; cout.flush(); - // === ���ɷ������������ === + // Build the shuffled array before reading any query (non-adaptive). + // mt19937_64 is fully specified by the C++ standard, so a seed always + // gives the same array. + mt19937_64 gen(seed); vector a(n); iota(a.begin(), a.end(), 1); for (int i = 0; i < n; i++) { int l = i; int r = min(n - 1, i + 2); - int j = rnd.next(l, r); // pick j uniformly in [i, min(n-1, i+2)] + int j = l + (int)(gen() % (unsigned long long)(r - l + 1)); // j in [i, min(n-1, i+2)] swap(a[i], a[j]); } diff --git a/algorithmic/problems/59/testdata/1.ans b/algorithmic/problems/59/testdata/1.ans index e69de29bb..e8ece2eab 100644 --- a/algorithmic/problems/59/testdata/1.ans +++ b/algorithmic/problems/59/testdata/1.ans @@ -0,0 +1 @@ +4682543008036744323 diff --git a/algorithmic/problems/59/testdata/10.ans b/algorithmic/problems/59/testdata/10.ans index e69de29bb..1a3d427ce 100644 --- a/algorithmic/problems/59/testdata/10.ans +++ b/algorithmic/problems/59/testdata/10.ans @@ -0,0 +1 @@ +4665595347724582534 diff --git a/algorithmic/problems/59/testdata/2.ans b/algorithmic/problems/59/testdata/2.ans index e69de29bb..2b978c98f 100644 --- a/algorithmic/problems/59/testdata/2.ans +++ b/algorithmic/problems/59/testdata/2.ans @@ -0,0 +1 @@ +6298157047471827134 diff --git a/algorithmic/problems/59/testdata/3.ans b/algorithmic/problems/59/testdata/3.ans index e69de29bb..69f6c8ef5 100644 --- a/algorithmic/problems/59/testdata/3.ans +++ b/algorithmic/problems/59/testdata/3.ans @@ -0,0 +1 @@ +8432601026400768333 diff --git a/algorithmic/problems/59/testdata/4.ans b/algorithmic/problems/59/testdata/4.ans index e69de29bb..88f248281 100644 --- a/algorithmic/problems/59/testdata/4.ans +++ b/algorithmic/problems/59/testdata/4.ans @@ -0,0 +1 @@ +7455188350278579998 diff --git a/algorithmic/problems/59/testdata/5.ans b/algorithmic/problems/59/testdata/5.ans index e69de29bb..0e6b96622 100644 --- a/algorithmic/problems/59/testdata/5.ans +++ b/algorithmic/problems/59/testdata/5.ans @@ -0,0 +1 @@ +8725120859544361105 diff --git a/algorithmic/problems/59/testdata/6.ans b/algorithmic/problems/59/testdata/6.ans index e69de29bb..8507d3c97 100644 --- a/algorithmic/problems/59/testdata/6.ans +++ b/algorithmic/problems/59/testdata/6.ans @@ -0,0 +1 @@ +4771353772681629557 diff --git a/algorithmic/problems/59/testdata/7.ans b/algorithmic/problems/59/testdata/7.ans index e69de29bb..c4723fe68 100644 --- a/algorithmic/problems/59/testdata/7.ans +++ b/algorithmic/problems/59/testdata/7.ans @@ -0,0 +1 @@ +7139176609565723312 diff --git a/algorithmic/problems/59/testdata/8.ans b/algorithmic/problems/59/testdata/8.ans index e69de29bb..31bd8359d 100644 --- a/algorithmic/problems/59/testdata/8.ans +++ b/algorithmic/problems/59/testdata/8.ans @@ -0,0 +1 @@ +6260355439719575607 diff --git a/algorithmic/problems/59/testdata/9.ans b/algorithmic/problems/59/testdata/9.ans index e69de29bb..eb0fe3a21 100644 --- a/algorithmic/problems/59/testdata/9.ans +++ b/algorithmic/problems/59/testdata/9.ans @@ -0,0 +1 @@ +6907737329080125505 diff --git a/algorithmic/tests/test_problem59.py b/algorithmic/tests/test_problem59.py new file mode 100644 index 000000000..da1dd5152 --- /dev/null +++ b/algorithmic/tests/test_problem59.py @@ -0,0 +1,172 @@ +"""The hidden array must come from a secret per-test seed, not testlib's fixed default (#208).""" +import hashlib +import math +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +DATA = ROOT / 'algorithmic/problems/59/testdata' +TESTS = range(1, 11) + +# The reproduction from #208: replay testlib's default-seeded rnd and answer with zero queries. +EXPLOIT = r''' +#include +using namespace std; +struct Rnd { + unsigned long long seed = 3905348978240129619ULL; + static const unsigned long long MUL = 0x5DEECE66DULL, ADD = 0xBULL, MASK = (1ULL<<48)-1; + static const long long LLMAX = 9223372036854775807LL; + long long nextBits(int bits){ + if (bits <= 48){ seed=(seed*MUL+ADD)&MASK; return (long long)(seed>>(48-bits)); } + long long left = nextBits(31) << 32; + long long right = nextBits(32); + return left ^ right; + } + long long next(long long n){ + const long long limit = LLMAX / n * n; + long long bits; do{ bits=nextBits(63); }while(bits>=limit); + return bits % n; + } + int next(int from,int to){ return (int)(next((long long)to - from + 1) + from); } +} rnd; +int main(){ + int n; if(!(cin>>n)) return 0; + vector a(n); iota(a.begin(),a.end(),1); + for(int i=0;i +int main(int argc, char** argv) { + std::mt19937_64 gen(std::strtoull(argv[1], nullptr, 10)); + int n; if (std::scanf("%d", &n) != 1) return 1; + std::vector a(n); std::iota(a.begin(), a.end(), 1); + for (int i = 0; i < n; i++) { + int r = std::min(n - 1, i + 2); + std::swap(a[i], a[i + (int)(gen() % (unsigned long long)(r - i + 1))]); + } + std::printf("!"); for (int v : a) std::printf(" %d", v); std::printf("\n"); +} +''' + + +def build(directory, name, source=None): + executable = directory / name + if source is None: + source = ROOT / 'algorithmic/problems/59/interactor.cpp' + else: + (directory / (name + '.cpp')).write_text(source) + source = directory / (name + '.cpp') + subprocess.run(['g++', '-std=gnu++17', '-O2', '-I' + str(ROOT / 'algorithmic/judge/include'), + str(source), '-o', str(executable)], check=True) + return executable + + +@pytest.fixture(scope='module') +def programs(tmp_path_factory): + directory = tmp_path_factory.mktemp('problem59') + return {'interactor': build(directory, 'interactor'), + 'exploit': build(directory, 'exploit', EXPLOIT), + 'oracle': build(directory, 'oracle', ORACLE)} + + +def seed(test): + return (DATA / f'{test}.ans').read_text().strip() + + +def answer(programs, name, test): + """Output of a zero-query contestant that only reads n.""" + args = [str(programs[name])] + ([seed(test)] if name == 'oracle' else []) + return subprocess.run(args, input=(DATA / f'{test}.in').read_text(), text=True, + capture_output=True, check=True, timeout=5).stdout + + +def play(programs, tmp_path, test, contestant, ans=None): + """Run the interactor on a test; the contestant's whole output is fed in at once (non-adaptive).""" + ans_file = tmp_path / 'ans' + ans_file.write_text(seed(test) + '\n' if ans is None else ans) + return subprocess.run([str(programs['interactor']), str(DATA / f'{test}.in'), + str(tmp_path / 'transcript'), str(ans_file)], + input=contestant, text=True, capture_output=True, timeout=10) + + +def ratio(result): + return float(re.search(r'Ratio: ([0-9.]+)', result.stderr).group(1)) + + +def test_each_test_has_its_own_secret_seed(): + seeds = [seed(t) for t in TESTS] + assert all(re.fullmatch(r'[0-9]+', s) and int(s) < 2**63 for s in seeds), seeds + assert len(set(seeds)) == len(seeds) + # The contestant only receives the .in file, which must hold n alone. + for t in TESTS: + assert re.fullmatch(r'[0-9]+\s*', (DATA / f'{t}.in').read_text()) + + +# Test 1 has n = 1, where the only array is (1) and zero queries is the honest answer. +@pytest.mark.parametrize('test', [t for t in TESTS if t != 1]) +def test_issue_replay_is_rejected(programs, tmp_path, test): + result = play(programs, tmp_path, test, answer(programs, 'exploit', test)) + assert result.returncode == 1, result.stderr + assert 'Wrong final array' in result.stderr + + +@pytest.mark.parametrize('test', TESTS) +def test_array_is_fixed_by_the_seed(programs, tmp_path, test): + final = answer(programs, 'oracle', test) + values = list(map(int, final.split()[1:])) + n = len(values) + queries = ''.join(f'? {i} {i + 1}\n' for i in range(1, n)) + runs = [play(programs, tmp_path, test, queries + final) for _ in range(2)] + for result in runs: + assert result.returncode == 7, result.stderr + replies = result.stdout.split() + assert replies[0] == str(n) + assert replies[1:] == ['<' if values[i] < values[i + 1] else '>' for i in range(n - 1)] + points = 100 if n == 1 else 60 + math.log((n * 5 // 3 + 5) / (n - 1)) + assert ratio(result) == pytest.approx(points / 100, abs=1e-9) + assert runs[0].stdout == runs[1].stdout and runs[0].stderr == runs[1].stderr + + +# Pins the hidden arrays, so repeated judging gives the same results. Changing a seed or +# the generator changes every score on this problem: update these only on purpose. +ARRAY_SHA256 = {1: '6929651cfa7ef638', 2: 'a0b0f48de997418c', 3: 'fd7599aa0e2a0bd9', 4: '65441a99d14bfa66', + 5: 'b4c7de6f59255de7', 6: 'e46573c8eba814ab', 7: 'ff7a1f66caa0e364', 8: '295e91118a0bc8b9', + 9: 'f453f2cd086e9164', 10: '93cab73c969610ac'} + + +def test_hidden_arrays_are_pinned(programs, tmp_path): + for t in TESTS: + assert hashlib.sha256(answer(programs, 'oracle', t).encode()).hexdigest()[:16] == ARRAY_SHA256[t], t + result = play(programs, tmp_path, 2, '! 1 2 4 5 6 3 9 10 7 8\n') + assert result.returncode == 7 and ratio(result) == 1.0, result.stderr + + +def test_tests_with_equal_n_get_different_arrays(programs): + by_n = {} + for t in TESTS: + by_n.setdefault((DATA / f'{t}.in').read_text().strip(), []).append(answer(programs, 'oracle', t)) + assert len(by_n['30000']) == 8 + for arrays in by_n.values(): + assert len(set(arrays)) == len(arrays) + + +@pytest.mark.parametrize('test', [2, 3]) +def test_seed_never_reaches_the_contestant_or_the_log(programs, tmp_path, test): + for contestant in (answer(programs, 'oracle', test), answer(programs, 'exploit', test)): + result = play(programs, tmp_path, test, contestant) + assert seed(test) not in result.stdout + result.stderr + assert result.stdout.split() == [(DATA / f'{test}.in').read_text().strip()] + + +def test_missing_seed_is_a_judge_failure(programs, tmp_path): + result = play(programs, tmp_path, 3, answer(programs, 'exploit', 3), ans='') + assert result.returncode == 3, result.stderr + assert result.stdout == '' diff --git a/algorithmic/tests/test_problem59_budget_reference.py b/algorithmic/tests/test_problem59_budget_reference.py index 6b7a760e5..cd7702d48 100644 --- a/algorithmic/tests/test_problem59_budget_reference.py +++ b/algorithmic/tests/test_problem59_budget_reference.py @@ -24,7 +24,7 @@ def play(binary, directory, queries, wrong=False): inp = directory / "input" ans = directory / "answer" inp.write_text(f"{n}\n") - ans.write_text("") + ans.write_text("1\n") # hidden seed p = subprocess.Popen([str(binary), str(inp), str(directory / "transcript"), str(ans)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)