From 8b92691d3a5602b4716a5633fbdea9f586193d3a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 21:22:52 -0400 Subject: [PATCH 01/26] Track B B0.2: plan, landscape, protocol v1 (draft) and count script B0.2's tree from PR #554 at 9a0ef5f4, as one commit on master. #554's history is kept on its branch; this commit carries no registry file. - Plan and landscape synthesis (docs/design/track_b_b0_2_plan.md, track_b_b0_2_landscape.md). - The structural-audit protocol, version 1, DRAFT, with the answers to Sol review rounds 1 to 3 and the conditions on B0.1's sets in its block (docs/design/track_b_b0_2_protocol.md; pin 036b2070...). - The count script, built on invented frames (scripts/ track_b_b0_2_counts.py), its tests, the evidence tools and the standing exclusion record. No count has run. Tiers: unit 18,254 -> 18,407 (+153 for B0.2's tests). Co-Authored-By: Claude Opus 5.5 --- .../design/track_b_b0_2_counts_mutations.json | 1027 ++++ docs/design/track_b_b0_2_counts_report.txt | 262 ++ .../design/track_b_b0_2_evidence/calibrate.py | 125 + .../track_b_b0_2_evidence/calibration.txt | 51 + .../track_b_b0_2_evidence/exhaust_closure.py | 62 + .../track_b_b0_2_evidence/exhaust_closure.txt | 1 + .../track_b_b0_2_evidence/invented_panel.py | 136 + .../track_b_b0_2_evidence/mutation_specs.json | 757 +++ .../design/track_b_b0_2_evidence/mutations.py | 182 + docs/design/track_b_b0_2_evidence/oracle.py | 149 + docs/design/track_b_b0_2_evidence/ranges.py | 73 + docs/design/track_b_b0_2_evidence/ranges.txt | 1 + docs/design/track_b_b0_2_evidence/search.py | 117 + docs/design/track_b_b0_2_evidence/search.txt | 1 + docs/design/track_b_b0_2_exclusions.json | 52 + docs/design/track_b_b0_2_landscape.md | 344 ++ docs/design/track_b_b0_2_plan.md | 163 + docs/design/track_b_b0_2_protocol.md | 634 +++ scripts/track_b_b0_1_review_root.py | 10 +- scripts/track_b_b0_2_counts.py | 1988 ++++++++ tests/README-tiers.md | 4 +- tests/test_track_b_b0_2_counts.py | 4120 +++++++++++++++++ tests/test_track_b_b0_2_exclusions.py | 116 + tests/test_track_b_b2_outcome_exclusions.py | 19 +- tests/tier_counts.json | 2 +- 25 files changed, 10387 insertions(+), 9 deletions(-) create mode 100644 docs/design/track_b_b0_2_counts_mutations.json create mode 100644 docs/design/track_b_b0_2_counts_report.txt create mode 100644 docs/design/track_b_b0_2_evidence/calibrate.py create mode 100644 docs/design/track_b_b0_2_evidence/calibration.txt create mode 100644 docs/design/track_b_b0_2_evidence/exhaust_closure.py create mode 100644 docs/design/track_b_b0_2_evidence/exhaust_closure.txt create mode 100644 docs/design/track_b_b0_2_evidence/invented_panel.py create mode 100644 docs/design/track_b_b0_2_evidence/mutation_specs.json create mode 100644 docs/design/track_b_b0_2_evidence/mutations.py create mode 100644 docs/design/track_b_b0_2_evidence/oracle.py create mode 100644 docs/design/track_b_b0_2_evidence/ranges.py create mode 100644 docs/design/track_b_b0_2_evidence/ranges.txt create mode 100644 docs/design/track_b_b0_2_evidence/search.py create mode 100644 docs/design/track_b_b0_2_evidence/search.txt create mode 100644 docs/design/track_b_b0_2_exclusions.json create mode 100644 docs/design/track_b_b0_2_landscape.md create mode 100644 docs/design/track_b_b0_2_plan.md create mode 100644 docs/design/track_b_b0_2_protocol.md create mode 100644 scripts/track_b_b0_2_counts.py create mode 100644 tests/test_track_b_b0_2_counts.py create mode 100644 tests/test_track_b_b0_2_exclusions.py diff --git a/docs/design/track_b_b0_2_counts_mutations.json b/docs/design/track_b_b0_2_counts_mutations.json new file mode 100644 index 000000000..2b4a0bdc8 --- /dev/null +++ b/docs/design/track_b_b0_2_counts_mutations.json @@ -0,0 +1,1027 @@ +{ + "schema_version": 1, + "verification_class": "structural-count protocol conformance", + "admitted_scope": "Invented frames only; no real-data run.", + "mutation_method": "One deliberate production behavior change per check, from docs/design/track_b_b0_2_evidence/mutation_specs.json. Each check runs the whole count test module in a fresh pytest process with its own Hypothesis database; a nonzero exit kills it. The original source bytes are restored and their SHA-256 verified after every check. No mutated implementation is committed.", + "protocol_sha256": "e9a39f445c8674ac3e0298239d2f8b5473c148495bb24d82870b9a3d70c799b6", + "restored_source_sha256": { + "scripts/track_b_b0_2_counts.py": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553" + }, + "result": "45/45 mutations killed", + "runner_command": "OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONDONTWRITEBYTECODE=1 .venv/bin/python docs/design/track_b_b0_2_evidence/mutations.py RECORD", + "test_command": "PYTHONPATH=$PWD/src:$PWD python -m pytest -q -rf -p no:cacheprovider tests/test_track_b_b0_2_counts.py", + "checks": [ + { + "mutation": "band edges", + "replacements": [ + { + "old": "OLDER_BANDS = ((\"62_64\", 62, 64), (\"65_66\", 65, 66), (\"67_69\", 67, 69))", + "new": "OLDER_BANDS = ((\"62_64\", 63, 64), (\"65_66\", 65, 66), (\"67_69\", 67, 69))", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_relation_the_rows_do_not_guarantee_is_not_registered", + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_keep_nonempty_2009_origins", + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_use_exact_lags_and_later_wave_band", + "tests/test_track_b_b0_2_counts.py::test_b3l_uses_2007_origin_and_fixed_origin_weights_for_later_support", + "tests/test_track_b_b0_2_counts.py::test_band_edges_and_b3_support_are_hand_counted" + ], + "summary": "5 failed, 115 passed in 37.81s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 38.3 + }, + { + "mutation": "lag set", + "replacements": [ + { + "old": "LAGS = (2, 4)", + "new": "LAGS = (2, 6)", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_keep_nonempty_2009_origins", + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_use_exact_lags_and_later_wave_band", + "tests/test_track_b_b0_2_counts.py::test_counts_are_byte_invariant_to_valid_labor_values", + "tests/test_track_b_b0_2_counts.py::test_main_records_provenance_and_checks_b01_size" + ], + "summary": "4 failed, 116 passed in 107.81s (0:01:47)", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 108.6 + }, + { + "mutation": "R_TA_PROXY age threshold", + "replacements": [ + { + "old": "proxy = proxy[_known_age(proxy[\"age\"]) & (proxy[\"age\"] >= 30)]", + "new": "proxy = proxy[_known_age(proxy[\"age\"]) & (proxy[\"age\"] > 30)]", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_main_allows_relative_local_copy_resolving_outside_repository", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_a_local_copy_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_an_output_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_public_write_failure_keeps_complete_local_copy_and_attempt", + "tests/test_track_b_b0_2_counts.py::test_main_records_provenance_and_checks_b01_size", + "tests/test_track_b_b0_2_counts.py::test_main_withheld_release_commits_provenance_and_status_only", + "tests/test_track_b_b0_2_counts.py::test_rosters_and_age_30_proxy_boundary_use_their_own_weights_and_ages" + ], + "summary": "8 failed, 112 passed in 33.34s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 33.9 + }, + { + "mutation": "valid-row rule", + "replacements": [ + { + "old": "rows[\"valid\"], [\"person_id\", \"reference_year\", \"acc_flag\"]", + "new": "rows[\"weight_positive\"], [\"person_id\", \"reference_year\", \"acc_flag\"]", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_use_exact_lags_and_later_wave_band", + "tests/test_track_b_b0_2_counts.py::test_outside_b2_excludes_entire_anchor_households_and_counts_later_units", + "tests/test_track_b_b0_2_counts.py::test_rosters_and_age_30_proxy_boundary_use_their_own_weights_and_ages", + "tests/test_track_b_b0_2_counts.py::test_valid_rows_require_present_role_positive_weight_and_role_validity" + ], + "summary": "4 failed, 116 passed in 34.64s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 35.1 + }, + { + "mutation": "B3L origin wave", + "replacements": [ + { + "old": "B3L_ORIGIN_WAVE = 2007", + "new": "B3L_ORIGIN_WAVE = 2009", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_b3l_uses_2007_origin_and_fixed_origin_weights_for_later_support" + ], + "summary": "1 failed, 119 passed in 32.89s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 33.4 + }, + { + "mutation": "sensitive-quantity threshold changed from 10 to 9", + "replacements": [ + { + "old": "DISCLOSURE_THRESHOLD = 10", + "new": "DISCLOSURE_THRESHOLD = 9", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_disclosed_target_stays_outside_one_to_nine", + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_only_a_narrow_target_of_true_size_one_to_nine_withholds[9]", + "tests/test_track_b_b0_2_counts.py::test_the_fixed_base_is_ten_with_no_ladder", + "tests/test_track_b_b0_2_counts.py::test_the_relaxation_proves_irrelevance_only_above_nine_and_a_half[9.0-narrow]", + "tests/test_track_b_b0_2_counts.py::test_the_relaxation_proves_irrelevance_only_above_nine_and_a_half[9.5-narrow]", + "tests/test_track_b_b0_2_counts.py::test_the_relaxation_proves_irrelevance_only_above_nine_and_a_half[None-narrow]" + ], + "summary": "7 failed, 113 passed in 37.31s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 37.9 + }, + { + "mutation": "effective sizes left uncoarsened", + "replacements": [ + { + "old": "_put(published, path, coarsen(value, base))", + "new": "_put(published, path, value if path[-1].endswith(\"_n_eff\") else coarsen(value, base))", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_the_committed_file_is_identical_for_every_protected_remainder" + ], + "summary": "2 failed, 118 passed in 39.05s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 39.7 + }, + { + "mutation": "local copy allowed inside repository", + "replacements": [ + { + "old": "raise SystemExit(\"local copy must be outside the repository\")", + "new": "pass # Deliberately permit repository-local copies.", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_main_refuses_local_copy_inside_repository_before_read[False]", + "tests/test_track_b_b0_2_counts.py::test_main_refuses_local_copy_inside_repository_before_read[True]", + "tests/test_track_b_b0_2_counts.py::test_main_refuses_local_copy_through_repository_symlink_before_read" + ], + "summary": "3 failed, 117 passed in 42.00s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 42.5 + }, + { + "mutation": "coarsening rounds halves down", + "replacements": [ + { + "old": "return int((Decimal(str(value)) / base + Decimal(\"0.5\")) // 1) * base", + "new": "return int((Decimal(str(value)) / base + Decimal(\"0.5\")).to_integral_value(rounding=\"ROUND_CEILING\") - 1) * base", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_disclosed_target_stays_outside_one_to_nine", + "tests/test_track_b_b0_2_counts.py::test_a_narrow_target_outside_one_to_nine_starts_another_round", + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_audit_programs_depend_only_on_the_publication", + "tests/test_track_b_b0_2_counts.py::test_coarsening_boundaries_round_halves_up[10-values0-expected0]", + "tests/test_track_b_b0_2_counts.py::test_coarsening_boundaries_round_halves_up[50-values1-expected1]", + "tests/test_track_b_b0_2_counts.py::test_count_intervals_are_exact_preimages_of_coarsening[10]", + "tests/test_track_b_b0_2_counts.py::test_count_intervals_are_exact_preimages_of_coarsening[50]", + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_effective_size_attack_registers_household_and_design_lower_bounds", + "tests/test_track_b_b0_2_counts.py::test_effective_sizes_are_coarsened_from_their_own_unrounded_values", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes3]", + "tests/test_track_b_b0_2_counts.py::test_main_records_provenance_and_checks_b01_size", + "tests/test_track_b_b0_2_counts.py::test_targets_are_classified_in_one_order_whatever_their_sizes" + ], + "summary": "14 failed, 106 passed in 46.75s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 47.3 + }, + { + "mutation": "effective-size lower bounds omitted", + "replacements": [ + { + "old": "for path, count_path in registry.effective_bounds.items():", + "new": "for path, count_path in (): # Deliberately omit effective bounds.", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_published_effective_size_lifts_its_count_interval" + ], + "summary": "1 failed, 119 passed in 39.23s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 39.7 + }, + { + "mutation": "protection width reduced to 3", + "replacements": [ + { + "old": "PROTECTION_WIDTH = 4", + "new": "PROTECTION_WIDTH = 3", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_the_fixed_base_is_ten_with_no_ladder" + ], + "summary": "2 failed, 118 passed in 36.97s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 37.6 + }, + { + "mutation": "unknown age counted in 80 and over", + "replacements": [ + { + "old": "return _known_age(ages) & numeric.between(low, high)", + "new": "return (_known_age(ages) & numeric.between(low, high)) | (~_known_age(ages) & (low == 80))", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_known_age_partition_and_reader_maximum_are_hand_counted", + "tests/test_track_b_b0_2_counts.py::test_missing_999_and_zero_ages_through_actual_named_readers" + ], + "summary": "2 failed, 118 passed in 47.45s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 48.0 + }, + { + "mutation": "frozen check removed", + "replacements": [ + { + "old": " _check_protocol()\n", + "new": " pass # Deliberately omit frozen protocol validation.\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_main_requires_frozen_pinned_protocol_before_any_input[draft]", + "tests/test_track_b_b0_2_counts.py::test_main_requires_frozen_pinned_protocol_before_any_input[pin]" + ], + "summary": "2 failed, 118 passed in 42.24s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 42.7 + }, + { + "mutation": "published B interval starts at B/2", + "replacements": [ + { + "old": "return max(int(value - base // 2), base), int(value + base // 2 - 1)", + "new": "return int(value - base // 2), int(value + base // 2 - 1)", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_coarsening_boundaries_round_halves_up[10-values0-expected0]", + "tests/test_track_b_b0_2_counts.py::test_coarsening_boundaries_round_halves_up[50-values1-expected1]", + "tests/test_track_b_b0_2_counts.py::test_count_intervals_are_exact_preimages_of_coarsening[10]", + "tests/test_track_b_b0_2_counts.py::test_count_intervals_are_exact_preimages_of_coarsening[50]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes3]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes4]" + ], + "summary": "7 failed, 113 passed in 27.48s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 27.9 + }, + { + "mutation": "80-and-over band ends at 80", + "replacements": [ + { + "old": "(\"80_plus\", 80, age67._MAX_AGE_CODE),", + "new": "(\"80_plus\", 80, 80),", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_band_edges_and_b3_support_are_hand_counted", + "tests/test_track_b_b0_2_counts.py::test_known_age_partition_and_reader_maximum_are_hand_counted", + "tests/test_track_b_b0_2_counts.py::test_missing_999_and_zero_ages_through_actual_named_readers" + ], + "summary": "3 failed, 117 passed in 36.51s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 37.0 + }, + { + "mutation": "2009 lag origins dropped", + "replacements": [ + { + "old": "if later not in WAVES:", + "new": "if later not in WAVES or wave == 2009:", + "occurrence": 1, + "anchor_occurrences": 2 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_keep_nonempty_2009_origins", + "tests/test_track_b_b0_2_counts.py::test_b3_lag_pairs_use_exact_lags_and_later_wave_band", + "tests/test_track_b_b0_2_counts.py::test_disclosure_integer_hidden_band_attack_is_blocked_on_full_release", + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_disclosure_registry_covers_all_statistics_and_correct_unit_types", + "tests/test_track_b_b0_2_counts.py::test_disclosure_unpublished_coresident_remainder_is_audited", + "tests/test_track_b_b0_2_counts.py::test_effective_size_attack_registers_household_and_design_lower_bounds", + "tests/test_track_b_b0_2_counts.py::test_main_allows_relative_local_copy_resolving_outside_repository", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_a_local_copy_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_an_output_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_public_write_failure_keeps_complete_local_copy_and_attempt", + "tests/test_track_b_b0_2_counts.py::test_main_records_provenance_and_checks_b01_size", + "tests/test_track_b_b0_2_counts.py::test_main_withheld_release_commits_provenance_and_status_only", + "tests/test_track_b_b0_2_counts.py::test_missing_999_and_zero_ages_through_actual_named_readers", + "tests/test_track_b_b0_2_counts.py::test_the_committed_file_is_identical_for_every_protected_remainder" + ], + "summary": "15 failed, 105 passed in 38.41s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 39.0 + }, + { + "mutation": "outside-B2 household exclusion", + "replacements": [ + { + "old": "outside = full[~full[\"interview\"].isin(domain[\"interview\"])]", + "new": "outside = full[~full[\"person_id\"].isin(domain[\"person_id\"])]", + "occurrence": "all", + "anchor_occurrences": 2 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_disclosure_unpublished_coresident_remainder_is_audited", + "tests/test_track_b_b0_2_counts.py::test_outside_b2_excludes_entire_anchor_households_and_counts_later_units", + "tests/test_track_b_b0_2_counts.py::test_the_committed_file_is_identical_for_every_protected_remainder" + ], + "summary": "3 failed, 117 passed in 44.16s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 44.8 + }, + { + "mutation": "attempt record not checked, allowing a silent retry", + "replacements": [ + { + "old": "if attempt.exists() or attempt.is_symlink():", + "new": "if False and (attempt.exists() or attempt.is_symlink()):", + "occurrence": 1, + "anchor_occurrences": 1 + }, + { + "old": "with attempt.open(\"x\", encoding=\"utf-8\") as stream:", + "new": "with attempt.open(\"w\", encoding=\"utf-8\") as stream:", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_attempt_record_survives_failed_start_and_refuses_retry", + "tests/test_track_b_b0_2_counts.py::test_main_refuses_attempt_record_through_existing_symlink" + ], + "summary": "2 failed, 118 passed in 44.83s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 45.4 + }, + { + "mutation": "effective bound for a published B starts at B/2", + "replacements": [ + { + "old": "return max(base, math.ceil(value - base / 2))", + "new": "return max(0, math.ceil(value - base / 2))", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_published_effective_size_lifts_its_count_interval", + "tests/test_track_b_b0_2_counts.py::test_an_effective_size_bounds_its_count_from_below[10-10]" + ], + "summary": "2 failed, 118 passed in 44.77s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 45.4 + }, + { + "mutation": "audit skips published subset differences", + "replacements": [ + { + "old": "if superset == subset or keys[superset][0] != keys[subset][0]:", + "new": "if True:", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_disclosure_audits_multi_atom_published_subset_differences" + ], + "summary": "2 failed, 118 passed in 47.71s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 48.4 + }, + { + "mutation": "continuous relaxation replaces integer programs", + "replacements": [ + { + "old": "integrality=np.full(self.variables, int(integral)),", + "new": "integrality=np.zeros(self.variables),", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_disclosed_target_stays_outside_one_to_nine", + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes0]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes1]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes2]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes3]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes4]", + "tests/test_track_b_b0_2_counts.py::test_main_records_provenance_and_checks_b01_size" + ], + "summary": "8 failed, 112 passed in 45.22s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 45.9 + }, + { + "mutation": "pool seeded with the true sizes", + "replacements": [ + { + "old": " pool = WitnessPool(len(truth))\n", + "new": " pool = WitnessPool(len(truth))\n pool.add(truth)\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_an_undetermined_sensitive_target_withholds", + "tests/test_track_b_b0_2_counts.py::test_unanswered_programs_withhold" + ], + "summary": "2 failed, 118 passed in 46.03s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 46.7 + }, + { + "mutation": "targets taken in order of true size", + "replacements": [ + { + "old": " for index in range(len(targets)):\n if index in disclosed:", + "new": " for index in sorted(range(len(targets)), key=lambda i: targets[i][\"true_size\"]):\n if index in disclosed:", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_targets_are_classified_in_one_order_whatever_their_sizes" + ], + "summary": "1 failed, 119 passed in 47.94s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 48.6 + }, + { + "mutation": "search windows centred on the true size", + "replacements": [ + { + "old": " low, high = pool.values(columns)\n", + "new": " low, high = pool.values(columns)\n low = high = target[\"true_size\"]\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_target_the_pool_shows_wide_runs_no_program", + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_audit_programs_depend_only_on_the_publication" + ], + "summary": "3 failed, 117 passed in 62.29s (0:01:02)", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 62.9 + }, + { + "mutation": "disclosure test widened to sizes 2-9", + "replacements": [ + { + "old": "if 1 <= targets[index][\"true_size\"] < DISCLOSURE_THRESHOLD:", + "new": "if 2 <= targets[index][\"true_size\"] < DISCLOSURE_THRESHOLD:", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_an_undetermined_sensitive_target_withholds", + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_main_allows_relative_local_copy_resolving_outside_repository", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_a_local_copy_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_an_output_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_public_write_failure_keeps_complete_local_copy_and_attempt", + "tests/test_track_b_b0_2_counts.py::test_main_withheld_release_commits_provenance_and_status_only", + "tests/test_track_b_b0_2_counts.py::test_only_a_narrow_target_of_true_size_one_to_nine_withholds[1]" + ], + "summary": "8 failed, 112 passed in 45.90s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 46.5 + }, + { + "mutation": "an empty pool releases", + "replacements": [ + { + "old": " # No program found a point, so nothing can be shown wide.\n status = \"withheld\"", + "new": " # No program found a point, so nothing can be shown wide.\n pass", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_unanswered_programs_withhold" + ], + "summary": "1 failed, 119 passed in 43.20s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 43.8 + }, + { + "mutation": "a single round, with no reclassification", + "replacements": [ + { + "old": " if not added:\n break\n", + "new": " break\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_narrow_target_outside_one_to_nine_starts_another_round" + ], + "summary": "1 failed, 119 passed in 46.30s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 47.0 + }, + { + "mutation": "disclosed targets left unconstrained", + "replacements": [ + { + "old": "self.disclosed = [tuple(target[\"columns\"]) for target in disclosed]", + "new": "self.disclosed = []", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_disclosed_target_stays_outside_one_to_nine", + "tests/test_track_b_b0_2_counts.py::test_a_narrow_target_outside_one_to_nine_starts_another_round" + ], + "summary": "2 failed, 118 passed in 49.13s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 49.8 + }, + { + "mutation": "pool keeps points a disclosure rules out", + "replacements": [ + { + "old": " pool.keep(system)\n", + "new": " pass\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_narrow_target_outside_one_to_nine_starts_another_round" + ], + "summary": "1 failed, 119 passed in 49.43s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 50.1 + }, + { + "mutation": "relaxation margin loosened to 8.5", + "replacements": [ + { + "old": "relaxed > DISCLOSURE_THRESHOLD - 0.5", + "new": "relaxed > DISCLOSURE_THRESHOLD - 1.5", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_the_relaxation_proves_irrelevance_only_above_nine_and_a_half[9.0-narrow]", + "tests/test_track_b_b0_2_counts.py::test_the_relaxation_proves_irrelevance_only_above_nine_and_a_half[9.5-narrow]" + ], + "summary": "2 failed, 118 passed in 39.90s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 40.4 + }, + { + "mutation": "a timed-out search proves absence", + "replacements": [ + { + "old": "proven_absent = proven_absent and status == 2", + "new": "proven_absent = proven_absent and status in (1, 2)", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_timed_out_search_proves_nothing[statuses0]", + "tests/test_track_b_b0_2_counts.py::test_a_timed_out_search_proves_nothing[statuses1]", + "tests/test_track_b_b0_2_counts.py::test_a_timed_out_search_proves_nothing[statuses2]" + ], + "summary": "3 failed, 117 passed in 47.29s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 48.0 + }, + { + "mutation": "solver witnesses used without the exact check", + "replacements": [ + { + "old": " if not self.feasible(sizes):\n return None\n", + "new": " if False:\n return None\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_witnesses_are_checked_exactly_and_never_taken_from_the_solver" + ], + "summary": "1 failed, 119 passed in 46.21s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 46.8 + }, + { + "mutation": "positive solver gap", + "replacements": [ + { + "old": "\"mip_rel_gap\": gap,", + "new": "\"mip_rel_gap\": 0.01,", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_programs_use_one_thread_sixty_seconds_and_exact_optima" + ], + "summary": "1 failed, 119 passed in 45.15s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 46.0 + }, + { + "mutation": "atoms assumed nonempty", + "replacements": [ + { + "old": " self.bounds = Bounds(\n 0,", + "new": " self.bounds = Bounds(\n np.concatenate([np.ones(self.atoms), np.zeros(binaries)]),", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_audit_agrees_with_an_exhaustive_reader", + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes0]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes1]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes2]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes3]", + "tests/test_track_b_b0_2_counts.py::test_integer_audit_extrema_agree_with_bruteforce[sizes4]", + "tests/test_track_b_b0_2_counts.py::test_main_records_provenance_and_checks_b01_size", + "tests/test_track_b_b0_2_counts.py::test_the_committed_file_is_identical_for_every_protected_remainder" + ], + "summary": "9 failed, 111 passed in 44.68s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 45.4 + }, + { + "mutation": "relations registered whether or not guaranteed", + "replacements": [ + { + "old": " if guaranteed:\n registry.relate(", + "new": " if True:\n registry.relate(", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_relation_the_rows_do_not_guarantee_is_not_registered" + ], + "summary": "1 failed, 119 passed in 48.62s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 49.4 + }, + { + "mutation": "a violated relation tolerated", + "replacements": [ + { + "old": " raise ValueError(f\"a guaranteed relation fails: {kind} {left}\")", + "new": " continue", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_violated_relation_is_a_defect_not_data" + ], + "summary": "1 failed, 119 passed in 48.65s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 49.3 + }, + { + "mutation": "relations dropped from the audit", + "replacements": [ + { + "old": " relations = model_relations(registry, model)\n", + "new": " relations = model_relations(registry, model)[:0]\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_main_withheld_release_commits_provenance_and_status_only" + ], + "summary": "1 failed, 119 passed in 45.74s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 46.4 + }, + { + "mutation": "public block reports the audited target count", + "replacements": [ + { + "old": " \"status\": audit[\"status\"],\n }", + "new": " \"status\": audit[\"status\"],\n \"audited\": len(audit[\"targets\"]),\n }", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_a_withheld_release_publishes_status_and_no_counts", + "tests/test_track_b_b0_2_counts.py::test_disclosure_public_block_holds_only_base_rules_and_status", + "tests/test_track_b_b0_2_counts.py::test_main_withheld_release_commits_provenance_and_status_only", + "tests/test_track_b_b0_2_counts.py::test_the_committed_file_is_identical_for_every_protected_remainder" + ], + "summary": "4 failed, 116 passed in 47.24s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 47.8 + }, + { + "mutation": "fractional ages accepted", + "replacements": [ + { + "old": "numeric.between(1, age67._MAX_AGE_CODE) & numeric.mod(1).eq(0)", + "new": "numeric.between(1, age67._MAX_AGE_CODE)", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_known_age_partition_and_reader_maximum_are_hand_counted" + ], + "summary": "1 failed, 119 passed in 45.62s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 46.4 + }, + { + "mutation": "negative coarsening input accepted", + "replacements": [ + { + "old": "if value < 0 or not np.isfinite(value):", + "new": "if not np.isfinite(value):", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_coarsening_refuses_negative_and_nonfinite_values[-0.5]", + "tests/test_track_b_b0_2_counts.py::test_coarsening_refuses_negative_and_nonfinite_values[-1]" + ], + "summary": "2 failed, 118 passed in 69.09s (0:01:09)", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 70.4 + }, + { + "mutation": "nonfinite coarsening input accepted", + "replacements": [ + { + "old": "if value < 0 or not np.isfinite(value):", + "new": "if value < 0:", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_coarsening_refuses_negative_and_nonfinite_values[inf]", + "tests/test_track_b_b0_2_counts.py::test_coarsening_refuses_negative_and_nonfinite_values[nan]" + ], + "summary": "2 failed, 118 passed in 142.64s (0:02:22)", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 145.0 + }, + { + "mutation": "output replaces an existing file", + "replacements": [ + { + "old": " os.link(temporary, path)\n", + "new": " os.replace(temporary, path)\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_a_local_copy_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_main_never_overwrites_an_output_that_appears_during_read", + "tests/test_track_b_b0_2_counts.py::test_write_new_leaves_no_file_under_its_name_on_failure[link]", + "tests/test_track_b_b0_2_counts.py::test_write_new_writes_every_byte_and_refuses_an_existing_name" + ], + "summary": "4 failed, 116 passed in 108.61s (0:01:48)", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 109.8 + }, + { + "mutation": "output not flushed to disk", + "replacements": [ + { + "old": " view = view[os.write(descriptor, view) :]\n os.fsync(descriptor)\n", + "new": " view = view[os.write(descriptor, view) :]\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_write_new_leaves_no_file_under_its_name_on_failure[fsync]" + ], + "summary": "1 failed, 119 passed in 51.65s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 52.3 + }, + { + "mutation": "a short write left incomplete", + "replacements": [ + { + "old": " while view:\n view = view[os.write(descriptor, view) :]\n", + "new": " os.write(descriptor, view)\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_write_new_completes_short_writes" + ], + "summary": "1 failed, 119 passed in 43.07s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 43.6 + }, + { + "mutation": "attempt record not flushed to disk", + "replacements": [ + { + "old": " stream.flush()\n os.fsync(stream.fileno())\n", + "new": " stream.flush()\n", + "occurrence": 1, + "anchor_occurrences": 1 + } + ], + "exit_code": 1, + "failed_tests": [ + "tests/test_track_b_b0_2_counts.py::test_attempt_record_is_flushed_to_disk" + ], + "summary": "1 failed, 119 passed in 48.86s", + "status": "killed", + "restored": true, + "restored_source_sha256": "d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553", + "elapsed_seconds": 49.8 + } + ] +} diff --git a/docs/design/track_b_b0_2_counts_report.txt b/docs/design/track_b_b0_2_counts_report.txt new file mode 100644 index 000000000..0bea19848 --- /dev/null +++ b/docs/design/track_b_b0_2_counts_report.txt @@ -0,0 +1,262 @@ +Track B B0.2 counts: revised protocol version 1, review round 1 + +Changes and review findings + Known ages are integers 1..cohorts.age67._MAX_AGE_CODE. Ages 0, 999, + fractions and other invalid values enter unknown_age, never an age band + or R_TA_PROXY. Roster bands plus unknown_age partition persons; the + youngest band is now 1_24. Tests use the actual named readers on invented + fixed-width products and hand-count ages 81/125 and 2009 lag origins. + Integer-bounds recovery and the unpublished co-resident remainder are + addressed by a complete set registry, membership-signature atoms, and + every sensitive published-set/subset difference. Wave households have + separate types; design clusters include stratum. Identical sets and + objectives share rows/audits. Sparse matrices feed two scipy.optimize.milp + programs per distinct sensitive quantity, with integer nonnegative atom + sizes, 60 seconds per program, zero MIP gap and one HiGHS thread. Both + proven optima and width >=4 are required. No optimum means unprotected. + Effective sizes supply the specified person/household/design-cluster + lower bounds. Intermediate rounding was removed before coarsening. + All counts/effective sizes use halves-up coarsening and the 10/50/100/500 + ladder. If no base passes, only the disclosure block is published. Public + metadata never names a quantity; sets/memberships are never serialized. + Unsuppressed counts, atom sizes and audit results go only to the local + copy. scipy.milp has no initial-solution argument; true atom sizes are + checked as a feasible witness instead. + Frozen status and the block pin are mandatory before input resolution. + An exclusive, fsynced version-specific attempt record holds head, script + SHA-256 and UTC start time before any PSID file can open. Its name is + independent of output filenames; the fixed B0.2 evidence folder prevents + changing the parent directory to bypass the version lock. Existing clean, + thread, path, symlink and exclusive-output protections remain in place. + +Protocol pin (block unchanged; status remains DRAFT) + f2fd0a3eaf7cbfd99da21d7f885ca05e9e6b62764fe4db895b0f28b84ccd4bc6 + Exact bytes from through , + excluding the following newline. No freeze or real-data run occurred. + +Invented realistic-size timing + 30,000 persons; 415 person sets; six effective sizes; + 413 membership atoms; sparse matrix 271 x 413 with 13,298 nonzeros. + Thirteen collection waves, age bands, retention/validity profiles, + accuracy subsets, lag intersections and origin/later support, plus rare + singleton cells to exercise the ladder. This is the measured invented + fixture, not a worst-case bound for all possible membership patterns. + B=10: 22 quantities audited, 21 failed. B=50: 22 audited, none failed. + All 88 integer programs reached proven optima. Construction plus the + complete audit/ladder: 132.475925s (2m12.48s), under 60 minutes; + audit/ladder alone: 132.240612s. MILPs dominated model construction. + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD PYTHONDONTWRITEBYTECODE=1 /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python /tmp/b02-r1-mutations-timing/benchmark.py > /tmp/b02-r1-mutations-timing/benchmark.log 2>&1 + INVENTED REALISTIC-SIZE AUDIT PASS + +Validation (serial; exact commands, with final lines) + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python -m pytest -p no:xdist -p no:cacheprovider -q tests/test_track_b_b0_2_counts.py tests/test_track_b_b0_1_counts.py > /tmp/b02-r1-tests.log 2>&1 + 95 passed in 885.84s (0:14:45) + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python -m pytest -p no:xdist -p no:cacheprovider -q tests/test_track_b_b0_1_counts.py > /tmp/b02-r1-b01.log 2>&1 + 22 passed in 142.04s (0:02:22) + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python -m black --version + python -m black, 26.5.1 (compiled: yes) + Python (CPython) 3.14.7 + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python -m black --check -l 79 scripts/track_b_b0_2_counts.py tests/test_track_b_b0_2_counts.py > /tmp/b02-r1-black.log 2>&1 + 2 files would be left unchanged. + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python -m ruff check scripts/track_b_b0_2_counts.py tests/test_track_b_b0_2_counts.py > /tmp/b02-r1-ruff.log 2>&1 + All checks passed! + +Mutations + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE PYTHONPATH=$PWD/src:$PWD PYTHONDONTWRITEBYTECODE=1 /Users/maxghenis/PolicyEngine/microcosm-dynamics/.venv/bin/python /tmp/b02-r1-mutations-timing/run_fork_mutations.py /private/tmp/b02-r1-mutations-timing/specs.json > /tmp/b02-r1-mutations-timing/fork-mutations.log 2>&1 + 20/20 mutations killed + FORK MUTATION REPORT RECORDED; SOURCE RESTORED + Six original structural mutations remain; threshold, raw-effective-size + and path mutations were adapted. The old subtraction-complement check + is replaced by the published-subset-difference audit mutation. + Serial fresh child processes reuse parent-cached dependency imports; + the parent never runs an optimizer. Every child evicts and reloads the + script and test module before its targeted pytest.main invocation. + Four pytest import-rewrite warnings per child concern cached Hypothesis + and AnyIO plugins, not the reloaded script or tests. + Exact pytest invocations and equivalent reproduction commands, old/new + source text, exit codes, failed nodes, final lines and restoration + digests are recorded in + docs/design/track_b_b0_2_counts_mutations.json. + 1. band edges: 1 failed, 4 warnings in 16.28s (killed). + 2. lag set: 1 failed, 4 warnings in 14.20s (killed). + 3. R_TA_PROXY age threshold: 1 failed, 4 warnings in 4.23s (killed). + 4. outside-B2 household exclusion: 1 failed, 4 warnings in 6.66s (killed). + 5. valid-row rule: 1 failed, 4 warnings in 6.47s (killed). + 6. B3L origin wave: 1 failed, 4 warnings in 16.08s (killed). + 7. sensitive-quantity threshold changed from 10 to 9: 1 failed, 10 passed, 4 warnings in 16.72s (killed). + 8. effective sizes left uncoarsened: 1 failed, 4 warnings in 30.49s (killed). + 9. local copy allowed inside repository: 2 failed, 4 warnings in 8.21s (killed). + 10. coarsening rounds halves down: 2 failed, 4 warnings in 4.89s (killed). + 11. atom audit skips published subset differences: 1 failed, 4 warnings in 7.31s (killed). + 12. effective-size lower bounds omitted: 1 failed, 4 warnings in 6.15s (killed). + 13. continuous relaxation replaces integer programs: 4 failed, 1 passed, 4 warnings in 6.39s (killed). + 14. protection width reduced to 3: 1 failed, 4 warnings in 2.77s (killed). + 15. disclosure ladder skipped: 1 failed, 4 warnings in 8.36s (killed). + 16. unknown age counted in 80 and over: 1 failed, 4 warnings in 33.10s (killed). + 17. frozen check removed: 2 failed, 4 warnings in 89.67s (0:01:29) (killed). + 18. attempt record not checked, allowing a silent retry: 1 failed, 4 warnings in 30.99s (killed). + 19. 80-and-over band ends at 80: 1 failed, 4 warnings in 27.70s (killed). + 20. 2009 lag origins dropped: 1 failed, 4 warnings in 31.88s (killed). + The silent-retry mutant disables both the early attempt check and + exclusive attempt creation; removing only one leaves the other defense. + Because valid effective sizes cannot exceed their associated published + count, identical monotone coarsening makes their lower bounds redundant + in ordinary valid tables. The omission mutation is tested by broadening + only the count interval in a unit test, isolating the required effective + bound; the true invented table remains valid. The independent household + effective-size attack and complete structural release also pass. + Every mutation restored the production bytes and verified SHA-256: + fd3e2b177e5da09482501dffbadd5c6ab347712dc5efc372fbaa59dedf207322 + +No-data and integrity declaration + No real data file or codebook was opened. All numerical fixtures and + fixed-width reader products were invented. No real-data count, power + calculation, default counts JSON or real evidence-folder attempt was + created. + No package was installed. The protocol, gates.yaml, runs/, + tests/tier_counts.json and existing src/ files were not edited. Only the + requested script, tests, mutation JSON and report changed. Branch + trackb-b0-2-20261021 remains at f5d22c1d21f4ec92dbd6239672ce98e33fc06d4e. + No commit or push was made, following the task's explicit no-commit rule. + +Post-build correction (main session, before review round 2) + count_interval gave a published multiple equal to B the interval + B/2..3B/2-1. Under the coarsening rule, "B" covers B..3B/2-1, because + smaller values are written " through . + Count script SHA-256, unchanged from 88211bf8 through bfd439cf: + d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553 + +Validation (serial, in a clean clone at bfd439cf) + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE + PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=$PWD/src:$PWD nice -n 10 + .venv/bin/python -m pytest -q -p no:cacheprovider + tests/test_track_b_b0_2_counts.py tests/test_track_b_b0_1_counts.py + tests/test_track_b_b0_2_exclusions.py + tests/test_track_b_b2_outcome_exclusions.py + tests/test_track_b_b0_1_review_root.py + 293 passed in 58.33s + git diff --name-only -z f8ffc7d9 HEAD -- '*.py' | xargs -0 + .venv/bin/python -m black -l 79 --workers 1 --check + 12 files would be left unchanged. + git diff --name-only -z f8ffc7d9 HEAD -- '*.py' | xargs -0 + .venv/bin/ruff check + All checks passed! + Tier counts: tests/tier_counts.json has unit 18,249 against 18,125 at + master f8ffc7d9 (this module collects 120 unit tests); the other tiers + are unchanged. + +Enumeration checks (docs/design/track_b_b0_2_evidence/, invented tables) + oracle.py restates the rule by brute force, without the integer + programs: every atom-size vector with the truth's publication, atoms + allowed to be empty, one unit type and no relation beyond the rows. + python docs/design/track_b_b0_2_evidence/exhaust_closure.py + structures 96 tables 263424 multi 0 + Every three-atom table (two or more rows, distinct nonempty + signatures, each atom 1 to 14): no release needs a second round. The + closure rounds are therefore tested by forcing one + (test_a_narrow_target_outside_one_to_nine_starts_another_round). + python docs/design/track_b_b0_2_evidence/search.py 2 1500 + {'tables': 562, 'released': 531, 'multi_round': 0, 'theorem_fail': 0, + 'width_fail': 0, 'single_unsound': 0} + Three to five atoms, at most 6,000 candidate vectors: on every + released table the reader's posterior equals the final constraint + set, and every sensitive target spans at least 4 over it. None needed + a second round, and a rule that stopped after its first round would + have left no sensitive target narrow on any of them. + python docs/design/track_b_b0_2_evidence/ranges.py 5 3000 + {'released': 1635, 'sensitive': 2665, 'gaps': 0, 'under5': 0} + On these tables every sensitive target also takes every size in its + range, so at least five sizes. The rule does not promise this; it is + an observation, and the protocol states only the range. + The oracle first built every vector in the caps' product before + filtering; during search.py that reached 30 GB, and the run was + stopped. It now decodes vectors in chunks of one million, with an + early limit. Over 1,228 random tables the two versions gave identical + arrays, order included. Each output above also equals the first + version's: exhaust_closure.py and ranges.py from runs of these scripts + before the change, and search.py from an earlier scratch version of + the same search (same seed, trials and draws) before it was tidied for + commit. + +Calibration on an invented panel (calibration.txt) + invented_panel.py draws, from a seeded generator, the shapes the count + script reads (person-waves, family labor validity, the 2011 anchor and + design fields) for 13 biennial waves, with attrition, deaths, + split-offs, missing ages, zero weights, nonsample sequence numbers and + invalid labor amounts. calibrate.py runs the script's registry, model + and audit at B = 10 and times each program. + 800 households: 2,843 targets (939 sensitive), released, every target + wide (2,773 from the pool, 70 by search), audit 298.5 s. + 3,000 households: 3,112 targets (1,446 sensitive), released, every + target wide (3,028 pool, 84 search), audit 699.2 s. + 9,000 households: 3,776 targets (2,244 sensitive), released, every + target wide (3,653 pool, 123 search), audit 705.2 s. + The slowest seed program took 22.3 s and the slowest target, over all + its programs, 19.1 s; each program's limit is 60 s. No round disclosed + a target. The staged PSID's atom and target counts are not known + before the run; the invented panel's are a calibration, not a bound. + +Mutations (docs/design/track_b_b0_2_counts_mutations.json) + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE + PYTHONDONTWRITEBYTECODE=1 nice -n 10 .venv/bin/python + docs/design/track_b_b0_2_evidence/mutations.py RECORD + FINAL: 45/45 mutations killed; restored + d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553 + A first run at 88211bf8 killed 42 of 45. Three survived, each a gap in + the tests: targets ordered by true size, a timed-out search counted as + proof, and relations registered whether or not guaranteed. bfd439cf + adds a test for each, and each fails on its mutant and passes on the + script: + test_targets_are_classified_in_one_order_whatever_their_sizes + test_a_timed_out_search_proves_nothing + test_a_relation_the_rows_do_not_guarantee_is_not_registered + The record is the full rerun at bfd439cf. + Each check makes one change from mutation_specs.json and runs the + whole count test module in a fresh pytest process with its own + Hypothesis database. 21 checks carry over from the earlier record, one + renamed ("atom audit skips published subset differences" is now "audit + skips published subset differences"); the ladder check is gone with + the ladder. 24 are new, covering the pool and its seeding, target + order, the search windows, the withhold test, the disclosure rounds, + the relaxation margin, timeouts, exact witness checks, the solver gap, + empty atoms, the relations, the public block, coarsening inputs, ages, + the output writes and the attempt record's flush. + Every check failed between 1 and 15 tests; 15 checks failed exactly + one. Each failed-test id in the record matches one of the 120 node ids + pytest collects from the module. + Every check restored the script's bytes and verified its SHA-256: + d0a96afe92699ed3ca9f7936b45a9154c115ac69e1b567decc70e454040f7553 + +No-data and integrity declaration + No real data file or codebook was opened. Every table and panel above + is invented. No real-data count, power calculation, counts JSON or + evidence-folder attempt record was created. gates.yaml, runs/ and + existing src/ files were not edited. diff --git a/docs/design/track_b_b0_2_evidence/calibrate.py b/docs/design/track_b_b0_2_evidence/calibrate.py new file mode 100644 index 000000000..dd2ca70d5 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/calibrate.py @@ -0,0 +1,125 @@ +"""Time B0.2's disclosure audit on an invented panel. + +INVENTED DATA ONLY. ``invented_panel.py`` builds the panel from a seeded +generator; no PSID file is read. The script builds the count script's +registry and model, runs its audit at the fixed base, and prints the +model's size, the audit's status and results, and its slowest targets. +As ``main()`` does, it registers B0.1's sets, rebuilt from the same panel, +at the counts B0.1's own script gives; ``--without-b0-1`` leaves them out, +as the runs before review round 3's answer did. +It wraps ``classify`` and ``AuditSystem.direction`` only to time them; the +audit itself is unchanged. + +Run from the repository root, with the thread settings the protocol uses:: + + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE \\ + PYTHONPATH=src:. python docs/design/track_b_b0_2_evidence/calibrate.py \\ + HOUSEHOLDS SEED [--without-b0-1] + +The output for the report is committed beside it as ``calibration.txt``. +""" + +from __future__ import annotations + +import sys +import time +from collections import Counter +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[2] / "scripts")) +sys.path.insert(0, str(HERE)) + +import track_b_b0_2_counts as script # noqa: E402 +from invented_panel import invented_inputs # noqa: E402 + + +def main(households: int, seed: int, with_b0_1: bool = True) -> None: + started = time.monotonic() + people, labor, anchor, design = invented_inputs(households, seed) + validity = { + wave: script.validity_frame(frame) for wave, frame in labor.items() + } + counts = script.structural_counts(people, validity, anchor, design) + registry = script.disclosure_registry(people, validity, anchor, design) + earlier = {} + if with_b0_1: + earlier = script.b0_1_sets(people, validity) + script.register_b0_1( + registry, earlier, script.b0_1_structural_counts(people, validity) + ) + model = script.build_model(registry) + sensitive = sum(1 <= t["true_size"] < 10 for t in model["targets"]) + print( + f"households={households} seed={seed} " + f"persons/wave~{len(people) // 13} sets={len(registry.sets)} " + f"b0_1_sets={len(earlier)} " + f"rows={model['matrix'].shape[0]} " + f"atoms={dict(Counter(model['unit_types']))} " + f"targets={len(model['targets'])} sensitive={sensitive} " + f"build={time.monotonic() - started:.1f}s", + flush=True, + ) + slow = [] + classify = script.classify + direction = script.AuditSystem.direction + + def timed_classify(system, target, pool, *, seconds): + start = time.monotonic() + outcome = classify(system, target, pool, seconds=seconds) + elapsed = time.monotonic() - start + record = outcome[0] + slow.append( + ( + round(elapsed, 1), + target["kind"], + target["true_size"], + record["result"], + record.get("proof"), + ) + ) + return outcome + + def timed_direction(self, weights, *, seconds): + start = time.monotonic() + point = direction(self, weights, seconds=seconds) + print( + f" seed program {time.monotonic() - start:.1f}s " + f"point={point is not None}", + flush=True, + ) + return point + + script.classify = timed_classify + script.AuditSystem.direction = timed_direction + start = time.monotonic() + audit = script.audit_counts(counts, registry, script.DISCLOSURE_BASE) + results = Counter(t["result"] for t in audit["targets"]) + proofs = Counter(t.get("proof") for t in audit["targets"]) + narrow_sensitive = sum( + t["result"] == "narrow" and 1 <= t["true_size"] < 10 + for t in audit["targets"] + ) + print( + f"B={audit['base']} status={audit['status']} " + f"rounds={[len(r['disclosed']) for r in audit['rounds']]} " + f"results={dict(results)} proofs={dict(proofs)} " + f"pool={audit['pool']} narrow_sensitive={narrow_sensitive} " + f"audit={time.monotonic() - start:.1f}s", + flush=True, + ) + print(" slowest targets:", sorted(slow, reverse=True)[:5], flush=True) + withholding = [ + (t["kind"], t["unit_type"], t["true_size"], t["earlier"]) + for t in audit["targets"] + if t["result"] == "narrow" and 1 <= t["true_size"] < 10 + ] + print(" narrow sensitive (kind, unit, size, earlier):", withholding) + + +if __name__ == "__main__": + main( + int(sys.argv[1]), + int(sys.argv[2]), + with_b0_1="--without-b0-1" not in sys.argv[3:], + ) diff --git a/docs/design/track_b_b0_2_evidence/calibration.txt b/docs/design/track_b_b0_2_evidence/calibration.txt new file mode 100644 index 000000000..d5cd3387d --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/calibration.txt @@ -0,0 +1,51 @@ +B0.2 disclosure audit on an invented panel (INVENTED DATA ONLY; no PSID file read). +Script: docs/design/track_b_b0_2_evidence/calibrate.py, with the bytes committed at +88211bf8, run 2026-10-24 from the repository root, one at a time, on a shared host +(load average about 100), with OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 +OMP_WAIT_POLICY=PASSIVE PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:. Times are wall +clock and vary with host load. Each program's limit is 60 s. +The 800- and 3,000-household runs loaded the count script as of 22fcd5b1 (SHA-256 +8793dc74...); the 9,000-household run loaded it as of 88211bf8 (d0a96afe...). The +two differ only in PROTOCOL_SHA256, which only main()'s protocol check reads. + +$ python docs/design/track_b_b0_2_evidence/calibrate.py 800 1 +households=800 seed=1 persons/wave~1341 sets=588 rows=462 atoms={'clusters': 156, 'households_1999': 5, 'households_2001': 6, 'households_2003': 6, 'households_2005': 6, 'households_2007': 61, 'households_2009': 5, 'households_2011': 72, 'households_2013': 10, 'households_2015': 10, 'households_2017': 6, 'households_2019': 5, 'households_2021': 6, 'households_2023': 6, 'persons': 471, 'strata': 80} targets=2843 sensitive=939 build=0.4s + seed program 0.8s point=True + seed program 2.8s point=True + seed program 2.6s point=True + seed program 3.0s point=True + seed program 3.0s point=True + seed program 2.3s point=True + seed program 3.7s point=True + seed program 2.7s point=True + seed program 3.1s point=True +B=10 status=released rounds=[0] results={'wide': 2843} proofs={'pool': 2773, 'search': 70} pool=79 narrow_sensitive=0 audit=298.5s + slowest targets: [(8.4, 'atom', 1, 'wide', 'search'), (8.3, 'atom', 1, 'wide', 'search'), (7.9, 'atom', 1, 'wide', 'search'), (7.7, 'atom', 1, 'wide', 'search'), (7.4, 'atom', 1, 'wide', 'search')] + +$ python docs/design/track_b_b0_2_evidence/calibrate.py 3000 2 +households=3000 seed=2 persons/wave~5108 sets=588 rows=456 atoms={'clusters': 160, 'households_1999': 6, 'households_2001': 6, 'households_2003': 6, 'households_2005': 6, 'households_2007': 88, 'households_2009': 6, 'households_2011': 115, 'households_2013': 10, 'households_2015': 10, 'households_2017': 6, 'households_2019': 6, 'households_2021': 6, 'households_2023': 6, 'persons': 1050, 'strata': 80} targets=3112 sensitive=1446 build=0.9s + seed program 2.5s point=True + seed program 4.2s point=True + seed program 15.2s point=True + seed program 8.2s point=True + seed program 4.0s point=True + seed program 4.7s point=True + seed program 10.4s point=True + seed program 17.6s point=True + seed program 22.3s point=True +B=10 status=released rounds=[0] results={'wide': 3112} proofs={'search': 84, 'pool': 3028} pool=93 narrow_sensitive=0 audit=699.2s + slowest targets: [(17.1, 'atom', 1, 'wide', 'search'), (16.3, 'atom', 2, 'wide', 'search'), (14.2, 'atom', 1, 'wide', 'search'), (13.5, 'atom', 1, 'wide', 'search'), (13.0, 'atom', 1, 'wide', 'search')] + +$ python docs/design/track_b_b0_2_evidence/calibrate.py 9000 3 +households=9000 seed=3 persons/wave~15348 sets=588 rows=431 atoms={'clusters': 160, 'households_1999': 6, 'households_2001': 6, 'households_2003': 6, 'households_2005': 6, 'households_2007': 123, 'households_2009': 6, 'households_2011': 151, 'households_2013': 11, 'households_2015': 11, 'households_2017': 6, 'households_2019': 6, 'households_2021': 6, 'households_2023': 6, 'persons': 1985, 'strata': 77} targets=3776 sensitive=2244 build=1.2s + seed program 2.2s point=True + seed program 7.1s point=True + seed program 5.2s point=True + seed program 1.7s point=True + seed program 4.7s point=True + seed program 5.0s point=True + seed program 5.2s point=True + seed program 5.3s point=True + seed program 3.9s point=True +B=10 status=released rounds=[0] results={'wide': 3776} proofs={'pool': 3653, 'search': 123} pool=132 narrow_sensitive=0 audit=705.2s + slowest targets: [(19.1, 'atom', 1, 'wide', 'search'), (17.9, 'atom', 1, 'wide', 'search'), (17.7, 'atom', 1, 'wide', 'search'), (17.1, 'atom', 1, 'wide', 'search'), (16.8, 'atom', 1, 'wide', 'search')] diff --git a/docs/design/track_b_b0_2_evidence/exhaust_closure.py b/docs/design/track_b_b0_2_evidence/exhaust_closure.py new file mode 100644 index 000000000..88ae5f214 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/exhaust_closure.py @@ -0,0 +1,62 @@ +"""Search every three-atom table for a release that needs a second round. + +INVENTED TABLES ONLY. A table here is any set of two or more published +rows over three atoms whose signatures (the rows that hold each atom) are +distinct and nonempty, and every size vector with each atom in 1 to 14. +For each, ``oracle.release`` applies the rule, and the script prints each +table whose release discloses a target in a closure round. It found none, +which is why the closure rounds are tested by a forced-round unit test +instead (``test_a_narrow_target_outside_one_to_nine_starts_another_round``). + +Run from the repository root:: + + python docs/design/track_b_b0_2_evidence/exhaust_closure.py + +The output is committed beside it as ``exhaust_closure.txt``. +""" + +from __future__ import annotations + +import sys +from itertools import combinations, product +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import oracle # noqa: E402 + +ATOMS = 3 + + +def main() -> None: + rows_available = [ + row + for size in range(1, ATOMS + 1) + for row in combinations(range(ATOMS), size) + ] + structures = 0 + tables = 0 + multi = 0 + for count in range(2, len(rows_available) + 1): + for rows in combinations(rows_available, count): + signatures = { + frozenset(j for j, row in enumerate(rows) if a in row) + for a in range(ATOMS) + } + if len(signatures) != ATOMS or frozenset() in signatures: + continue + structures += 1 + target_list = oracle.targets(rows, ATOMS) + for sizes in product(range(1, 15), repeat=ATOMS): + tables += 1 + status, disclosed, _ = oracle.release( + list(rows), list(sizes), None, target_list + ) + if disclosed: + multi += 1 + print("MULTI", rows, sizes, status, disclosed) + print(f"structures {structures} tables {tables} multi {multi}") + + +if __name__ == "__main__": + main() diff --git a/docs/design/track_b_b0_2_evidence/exhaust_closure.txt b/docs/design/track_b_b0_2_evidence/exhaust_closure.txt new file mode 100644 index 000000000..dc0f21100 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/exhaust_closure.txt @@ -0,0 +1 @@ +structures 96 tables 263424 multi 0 diff --git a/docs/design/track_b_b0_2_evidence/invented_panel.py b/docs/design/track_b_b0_2_evidence/invented_panel.py new file mode 100644 index 000000000..0dce2ef04 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/invented_panel.py @@ -0,0 +1,136 @@ +"""An invented PSID-shaped panel for B0.2's disclosure calibration. + +INVENTED DATA ONLY. Every value is drawn from a seeded generator, and no +PSID file, codebook or survey record is read. The panel has the shapes +B0.2's count script consumes (person-waves, family labor validity, the +2011 anchor and design fields) for 13 biennial waves, with attrition, +deaths, split-offs, missing ages (999), zero weights, nonsample sequence +numbers and invalid labor amounts. ``calibrate.py`` beside it uses it. +""" + +import numpy as np +import pandas as pd + +WAVES = tuple(range(1999, 2024, 2)) +SENTINEL = 9_999_998 + + +def invented_inputs(households=9000, seed=0, strata=80): + rng = np.random.default_rng(seed) + people = [] + labor = {w: [] for w in WAVES} + next_person = 1 + next_household = 1 + live = ( + [] + ) # households: dict(id, members=[(pid, rel, birth)], stratum, cluster) + + def new_household(head_age, wave, spouse=True): + nonlocal next_person, next_household + birth = wave - head_age + members = [(next_person, 10, birth)] + next_person += 1 + if spouse: + members.append((next_person, 20, birth + int(rng.integers(-6, 7)))) + next_person += 1 + for _ in range(int(rng.poisson(0.9 if head_age < 55 else 0.1))): + members.append( + (next_person, 30, birth + int(rng.integers(18, 40))) + ) + next_person += 1 + household = { + "id": next_household, + "members": members, + "stratum": int(rng.integers(1, strata + 1)), + "cluster": int(rng.integers(1, 3)), + } + next_household += 1 + return household + + for _ in range(households): + live.append( + new_household(int(rng.integers(18, 95)), 1999, rng.random() < 0.55) + ) + design = {} + for wave in WAVES: + survivors = [] + for household in live: + if rng.random() < 0.035: # attrition + continue + members = [] + for pid, rel, birth in household["members"]: + age = wave - birth + if rng.random() < ( + 0.002 if age < 60 else 0.02 + (age - 60) * 0.004 + ): + continue + members.append((pid, rel, birth)) + if not members: + continue + if all(rel != 10 for _, rel, _ in members): + pid, _, birth = members[0] + members[0] = (pid, 10, birth) + household["members"] = members + survivors.append(household) + for _ in range(int(households * 0.03)): # split-offs and new entrants + survivors.append( + new_household( + int(rng.integers(18, 35)), wave, rng.random() < 0.4 + ) + ) + live = survivors + for household in live: + interview = wave * 100_000 + household["id"] + for sequence, (pid, rel, birth) in enumerate( + household["members"], 1 + ): + age = wave - birth + if rng.random() < 0.004: + age = 999 + weight = ( + 0.0 + if rng.random() < 0.02 + else float(rng.lognormal(0, 0.6)) + ) + present = sequence if rng.random() > 0.03 else 51 + people.append( + (pid, wave, age, min(present, 51), rel, weight, interview) + ) + design.setdefault( + pid, (household["stratum"], household["cluster"]) + ) + labor[wave].append( + { + "interview": interview, + "head_labor": ( + float(SENTINEL) if rng.random() < 0.03 else 1.0 + ), + "spouse_labor": ( + float(SENTINEL) if rng.random() < 0.05 else 0.0 + ), + "head_acc": int(rng.random() < 0.1), + "spouse_acc": int(rng.random() < 0.08), + } + ) + columns = [ + "person_id", + "period", + "age", + "sequence", + "relationship", + "weight", + "interview", + ] + person_wave = pd.DataFrame(people, columns=columns) + labor = {w: pd.DataFrame(rows) for w, rows in labor.items()} + anchor = ( + person_wave.loc[person_wave["period"] == 2011] + .drop(columns="period") + .copy() + ) + anchor["weight"] = anchor["weight"] * rng.uniform(0.8, 1.2, len(anchor)) + design = pd.DataFrame( + [(pid, s, c) for pid, (s, c) in design.items()], + columns=["person_id", "stratum", "cluster"], + ) + return person_wave, labor, anchor, design diff --git a/docs/design/track_b_b0_2_evidence/mutation_specs.json b/docs/design/track_b_b0_2_evidence/mutation_specs.json new file mode 100644 index 000000000..a12b78692 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/mutation_specs.json @@ -0,0 +1,757 @@ +[ + { + "mutation": "band edges", + "replacements": [ + { + "old": "OLDER_BANDS = ((\"62_64\", 62, 64), (\"65_66\", 65, 66), (\"67_69\", 67, 69))", + "new": "OLDER_BANDS = ((\"62_64\", 63, 64), (\"65_66\", 65, 66), (\"67_69\", 67, 69))", + "occurrence": 1 + } + ] + }, + { + "mutation": "lag set", + "replacements": [ + { + "old": "LAGS = (2, 4)", + "new": "LAGS = (2, 6)", + "occurrence": 1 + } + ] + }, + { + "mutation": "R_TA_PROXY age threshold", + "replacements": [ + { + "old": "proxy = proxy[_known_age(proxy[\"age\"]) & (proxy[\"age\"] >= 30)]", + "new": "proxy = proxy[_known_age(proxy[\"age\"]) & (proxy[\"age\"] > 30)]", + "occurrence": 1 + } + ] + }, + { + "mutation": "valid-row rule", + "replacements": [ + { + "old": "rows[\"valid\"], [\"person_id\", \"reference_year\", \"acc_flag\"]", + "new": "rows[\"weight_positive\"], [\"person_id\", \"reference_year\", \"acc_flag\"]", + "occurrence": 1 + } + ] + }, + { + "mutation": "B3L origin wave", + "replacements": [ + { + "old": "B3L_ORIGIN_WAVE = 2007", + "new": "B3L_ORIGIN_WAVE = 2009", + "occurrence": 1 + } + ] + }, + { + "mutation": "sensitive-quantity threshold changed from 10 to 9", + "replacements": [ + { + "old": "DISCLOSURE_THRESHOLD = 10", + "new": "DISCLOSURE_THRESHOLD = 9", + "occurrence": 1 + } + ] + }, + { + "mutation": "effective sizes left uncoarsened", + "replacements": [ + { + "old": "_put(published, path, coarsen(value, base))", + "new": "_put(published, path, value if path[-1].endswith(\"_n_eff\") else coarsen(value, base))", + "occurrence": 1 + } + ] + }, + { + "mutation": "local copy allowed inside repository", + "replacements": [ + { + "old": "raise SystemExit(\"local copy must be outside the repository\")", + "new": "pass # Deliberately permit repository-local copies.", + "occurrence": 1 + } + ] + }, + { + "mutation": "coarsening rounds halves down", + "replacements": [ + { + "old": "return int((Decimal(str(value)) / base + Decimal(\"0.5\")) // 1) * base", + "new": "return int((Decimal(str(value)) / base + Decimal(\"0.5\")).to_integral_value(rounding=\"ROUND_CEILING\") - 1) * base", + "occurrence": 1 + } + ] + }, + { + "mutation": "effective-size lower bounds omitted", + "replacements": [ + { + "old": "for path, count_path in registry.effective_bounds.items():", + "new": "for path, count_path in (): # Deliberately omit effective bounds.", + "occurrence": 1 + } + ] + }, + { + "mutation": "protection width reduced to 3", + "replacements": [ + { + "old": "PROTECTION_WIDTH = 4", + "new": "PROTECTION_WIDTH = 3", + "occurrence": 1 + } + ] + }, + { + "mutation": "unknown age counted in 80 and over", + "replacements": [ + { + "old": "return _known_age(ages) & numeric.between(low, high)", + "new": "return (_known_age(ages) & numeric.between(low, high)) | (~_known_age(ages) & (low == 80))", + "occurrence": 1 + } + ] + }, + { + "mutation": "frozen check removed", + "replacements": [ + { + "old": " _check_protocol()\n", + "new": " pass # Deliberately omit frozen protocol validation.\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "published B interval starts at B/2", + "replacements": [ + { + "old": "return max(int(value - base // 2), base), int(value + base // 2 - 1)", + "new": "return int(value - base // 2), int(value + base // 2 - 1)", + "occurrence": 1 + } + ] + }, + { + "mutation": "80-and-over band ends at 80", + "replacements": [ + { + "old": "(\"80_plus\", 80, age67._MAX_AGE_CODE),", + "new": "(\"80_plus\", 80, 80),", + "occurrence": 1 + } + ] + }, + { + "mutation": "2009 lag origins dropped", + "replacements": [ + { + "old": "if later not in WAVES:", + "new": "if later not in WAVES or wave == 2009:", + "occurrence": 1 + } + ] + }, + { + "mutation": "outside-B2 household exclusion", + "replacements": [ + { + "old": "outside = full[~full[\"interview\"].isin(domain[\"interview\"])]", + "new": "outside = full[~full[\"person_id\"].isin(domain[\"person_id\"])]", + "occurrence": "all" + } + ] + }, + { + "mutation": "attempt record not checked, allowing a silent retry", + "replacements": [ + { + "old": "if attempt.exists() or attempt.is_symlink():", + "new": "if False and (attempt.exists() or attempt.is_symlink()):", + "occurrence": 1 + }, + { + "old": "with attempt.open(\"x\", encoding=\"utf-8\") as stream:", + "new": "with attempt.open(\"w\", encoding=\"utf-8\") as stream:", + "occurrence": 1 + } + ] + }, + { + "mutation": "effective bound for a published B starts at B/2", + "replacements": [ + { + "old": "return max(base, math.ceil(value - base / 2))", + "new": "return max(0, math.ceil(value - base / 2))", + "occurrence": 1 + } + ] + }, + { + "mutation": "audit skips published subset differences", + "replacements": [ + { + "old": "if superset == subset or keys[superset][0] != keys[subset][0]:", + "new": "if True:", + "occurrence": 1 + } + ] + }, + { + "mutation": "continuous relaxation replaces integer programs", + "replacements": [ + { + "old": "integrality=np.full(self.variables, int(integral)),", + "new": "integrality=np.zeros(self.variables),", + "occurrence": 1 + } + ] + }, + { + "mutation": "pool seeded with the true sizes", + "replacements": [ + { + "old": " pool = WitnessPool(len(truth))\n", + "new": " pool = WitnessPool(len(truth))\n pool.add(truth)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "targets taken in order of true size", + "replacements": [ + { + "old": " for index in range(len(targets)):\n if index in disclosed:", + "new": " for index in sorted(range(len(targets)), key=lambda i: targets[i][\"true_size\"]):\n if index in disclosed:", + "occurrence": 1 + } + ] + }, + { + "mutation": "search windows centred on the true size", + "replacements": [ + { + "old": " low, high = pool.values(columns)\n", + "new": " low, high = pool.values(columns)\n low = high = target[\"true_size\"]\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "disclosure test widened to sizes 2-9", + "replacements": [ + { + "old": "if 1 <= targets[index][\"true_size\"] < DISCLOSURE_THRESHOLD:", + "new": "if 2 <= targets[index][\"true_size\"] < DISCLOSURE_THRESHOLD:", + "occurrence": 1 + } + ] + }, + { + "mutation": "an empty pool releases", + "replacements": [ + { + "old": " # No program found a point, so nothing can be shown wide.\n status = \"withheld\"", + "new": " # No program found a point, so nothing can be shown wide.\n pass", + "occurrence": 1 + } + ] + }, + { + "mutation": "a single round, with no reclassification", + "replacements": [ + { + "old": " if not added:\n break\n", + "new": " break\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "disclosed targets left unconstrained", + "replacements": [ + { + "old": "self.disclosed = [tuple(target[\"columns\"]) for target in disclosed]", + "new": "self.disclosed = []", + "occurrence": 1 + } + ] + }, + { + "mutation": "pool keeps points a disclosure rules out", + "replacements": [ + { + "old": " pool.keep(system)\n", + "new": " pass\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "relaxation margin loosened to 8.5", + "replacements": [ + { + "old": "relaxed > DISCLOSURE_THRESHOLD - 0.5", + "new": "relaxed > DISCLOSURE_THRESHOLD - 1.5", + "occurrence": 1 + } + ] + }, + { + "mutation": "a timed-out search proves absence", + "replacements": [ + { + "old": "proven_absent = proven_absent and status == 2", + "new": "proven_absent = proven_absent and status in (1, 2)", + "occurrence": 1 + } + ] + }, + { + "mutation": "solver witnesses used without the exact check", + "replacements": [ + { + "old": " if not self.feasible(sizes):\n return None\n", + "new": " if False:\n return None\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "positive solver gap", + "replacements": [ + { + "old": "\"mip_rel_gap\": gap,", + "new": "\"mip_rel_gap\": 0.01,", + "occurrence": 1 + } + ] + }, + { + "mutation": "atoms assumed nonempty", + "replacements": [ + { + "old": " self.bounds = Bounds(\n 0,", + "new": " self.bounds = Bounds(\n np.concatenate([np.ones(self.atoms), np.zeros(binaries)]),", + "occurrence": 1 + } + ] + }, + { + "mutation": "relations registered whether or not guaranteed", + "replacements": [ + { + "old": " if guaranteed:\n registry.relate(", + "new": " if True:\n registry.relate(", + "occurrence": 1 + } + ] + }, + { + "mutation": "a violated relation tolerated", + "replacements": [ + { + "old": " raise ValueError(f\"a guaranteed relation fails: {kind} {left}\")", + "new": " continue", + "occurrence": 1 + } + ] + }, + { + "mutation": "relations dropped from the audit", + "replacements": [ + { + "old": " relations = model_relations(registry, model)\n", + "new": " relations = model_relations(registry, model)[:0]\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "public block reports the audited target count", + "replacements": [ + { + "old": " \"status\": audit[\"status\"],\n }", + "new": " \"status\": audit[\"status\"],\n \"audited\": len(audit[\"targets\"]),\n }", + "occurrence": 1 + } + ] + }, + { + "mutation": "fractional ages accepted", + "replacements": [ + { + "old": "numeric.between(1, age67._MAX_AGE_CODE) & numeric.mod(1).eq(0)", + "new": "numeric.between(1, age67._MAX_AGE_CODE)", + "occurrence": 1 + } + ] + }, + { + "mutation": "negative coarsening input accepted", + "replacements": [ + { + "old": "if value < 0 or not np.isfinite(value):", + "new": "if not np.isfinite(value):", + "occurrence": 1 + } + ] + }, + { + "mutation": "nonfinite coarsening input accepted", + "replacements": [ + { + "old": "if value < 0 or not np.isfinite(value):", + "new": "if value < 0:", + "occurrence": 1 + } + ] + }, + { + "mutation": "output replaces an existing file", + "replacements": [ + { + "old": " os.link(temporary, path)\n", + "new": " os.replace(temporary, path)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "output not flushed to disk", + "replacements": [ + { + "old": " view = view[os.write(descriptor, view) :]\n os.fsync(descriptor)\n", + "new": " view = view[os.write(descriptor, view) :]\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "a short write left incomplete", + "replacements": [ + { + "old": " while view:\n view = view[os.write(descriptor, view) :]\n", + "new": " os.write(descriptor, view)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "attempt record not flushed to disk", + "replacements": [ + { + "old": " stream.flush()\n os.fsync(stream.fileno())\n", + "new": " stream.flush()\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's sets left out of the model", + "replacements": [ + { + "old": " for path, (unit_type, units, _) in registry.prior.items():\n grouped.setdefault((unit_type, units), []).append(path)\n", + "new": "", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's counts bound their sets only from below", + "replacements": [ + { + "old": " lower[row] = max(lower[row], value)\n upper[row] = min(upper[row], value)\n", + "new": " lower[row] = max(lower[row], value)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "targets are the refined atoms, not this release's classes", + "replacements": [ + { + "old": " rows = tuple(row for row in signature if current[row])\n", + "new": " rows = tuple(signature)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "differences of two B0.1 sets audited as targets", + "replacements": [ + { + "old": " and not (current[subset] and current[superset])\n ):\n", + "new": " and False\n ):\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "a B0.1 count not checked against its set", + "replacements": [ + { + "old": " if value != len(units):\n", + "new": " if False:\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's rerun not compared with its record", + "replacements": [ + { + "old": " if rerun != counts:\n", + "new": " if False:\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's sums not checked", + "replacements": [ + { + "old": " if value != sum(leaves[part] for part in sums[path]):\n", + "new": " if False:\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "a modeled set B0.1 never published accepted", + "replacements": [ + { + "old": " if missing := set(sets) - set(leaves):\n", + "new": " if missing := set():\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's ledger copies not checked", + "replacements": [ + { + "old": " if copied != expected:\n", + "new": " if False:\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "a change pair's row count registered with its persons added", + "replacements": [ + { + "old": " value = sum(sign * _cell(counts, part) for sign, part in terms)\n", + "new": " value = sum(_cell(counts, part) for sign, part in terms)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's sets not registered in main()", + "replacements": [ + { + "old": " register_b0_1(registry, earlier, committed[\"counts\"])\n", + "new": "", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's record not checked before the audit", + "replacements": [ + { + "old": " check_b0_1_record(\n committed,\n b0_1_structural_counts(person_wave, validity_by_wave),\n earlier,\n )\n", + "new": "", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's age support ends a year early", + "replacements": [ + { + "old": " in_support=valid[\"age\"].between(low, high).to_numpy(),\n", + "new": " in_support=valid[\"age\"].between(low, high - 1).to_numpy(),\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's dispositions inverted", + "replacements": [ + { + "old": " [person for person, label in labels.items() if label == name],\n", + "new": " [person for person, label in labels.items() if label != name],\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "an empty pool classified as wide", + "replacements": [ + { + "old": " if pool.size == 0:\n return {**record, \"result\": \"narrow\", \"proof\": \"undetermined\"}, None\n", + "new": " if pool.size == 0:\n return {**record, \"result\": \"wide\", \"proof\": \"pool\"}, None\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "an emptied pool not reseeded", + "replacements": [ + { + "old": " if reseeded:\n # The disclosures ruled out every point: seed again under them.\n seed_pool(system, pool, seconds=seconds)\n", + "new": " if reseeded:\n # The disclosures ruled out every point: seed again under them.\n pass\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "an empty reseeding releases", + "replacements": [ + { + "old": " seed_pool(system, pool, seconds=seconds)\n if pool.size == 0:\n status = \"withheld\"\n", + "new": " seed_pool(system, pool, seconds=seconds)\n if pool.size == 0:\n status = \"released\"\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "seed directions drawn without the fixed seed", + "replacements": [ + { + "old": " generator = np.random.default_rng(POOL_SEED)\n", + "new": " generator = np.random.default_rng()\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "targets involving B0.1's sets marked as this release's", + "replacements": [ + { + "old": " \"earlier\": not (current[superset] and current[subset]),\n", + "new": " \"earlier\": False,\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "the local copy writes atom columns", + "replacements": [ + { + "old": " for key in TARGET_FIELDS\n", + "new": " for key in (*TARGET_FIELDS, \"columns\")\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "the local copy sums every target into class sizes", + "replacements": [ + { + "old": " if target[\"kind\"] == \"atom\":\n class_sizes.setdefault(", + "new": " if True:\n class_sizes.setdefault(", + "occurrence": 1 + } + ] + }, + { + "mutation": "B0.1's households taken at each row's wave", + "replacements": [ + { + "old": " home[person] for person in persons if pd.notna(home[person])\n", + "new": " person for person in persons if pd.notna(home[person])\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "the local copy writes the atom sizes", + "replacements": [ + { + "old": " \"class_sizes\": class_sizes,\n }\n", + "new": " \"class_sizes\": class_sizes,\n \"atom_sizes\": audit[\"atom_sizes\"],\n }\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "the audit prints the atom sizes", + "replacements": [ + { + "old": " truth = model[\"sizes\"]\n", + "new": " truth = model[\"sizes\"]\n print(truth.tolist(), file=sys.stderr)\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "a failure keeps a library's message", + "replacements": [ + { + "old": " return f\"{text}: {error}\" if raised_here else text\n", + "new": " return f\"{text}: {error}\"\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "a warning prints its message", + "replacements": [ + { + "old": " f\"B0.2 warning: {warning.category.__name__} at \"\n", + "new": " f\"B0.2 warning: {warning.message} at \"\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "the solver prints its log", + "replacements": [ + { + "old": " \"disp\": False,\n", + "new": " \"disp\": True,\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "the rerun gate skips the rebuilt sets' sizes", + "replacements": [ + { + "old": " if len(units) != sum(sign * leaves[part] for sign, part in terms):\n", + "new": " if False:\n", + "occurrence": 1 + } + ] + }, + { + "mutation": "differences found in row order, not this release's first", + "replacements": [ + { + "old": " pairs.sort(key=lambda pair: not (current[pair[0]] and current[pair[1]]))\n", + "new": "", + "occurrence": 1 + } + ] + }, + { + "mutation": "two of this release's sets that B0.1 also published make no target", + "replacements": [ + { + "old": " and earlier[superset]\n and not (current[subset] and current[superset])\n ):\n", + "new": " and earlier[superset]\n ):\n", + "occurrence": 1 + } + ] + } +] diff --git a/docs/design/track_b_b0_2_evidence/mutations.py b/docs/design/track_b_b0_2_evidence/mutations.py new file mode 100644 index 000000000..8080238e4 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/mutations.py @@ -0,0 +1,182 @@ +"""Run B0.2's mutation checks against the count script's full test module. + +Each check in ``mutation_specs.json`` makes one deliberate change to +``scripts/track_b_b0_2_counts.py``, runs all of +``tests/test_track_b_b0_2_counts.py`` in a fresh pytest process with its +own Hypothesis database, then restores the script's bytes and verifies +their SHA-256. A check is killed when pytest exits nonzero. The script +must be unmodified in git before the run starts. The record is written in +the format of ``docs/design/track_b_b0_2_counts_mutations.json``. + +Run from the root of a clean clone, with the interpreter that runs the +tests:: + + OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 OMP_WAIT_POLICY=PASSIVE \\ + PYTHONDONTWRITEBYTECODE=1 \\ + python docs/design/track_b_b0_2_evidence/mutations.py RECORD +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +TARGET = ROOT / "scripts/track_b_b0_2_counts.py" +PROTOCOL = ROOT / "docs/design/track_b_b0_2_protocol.md" +TESTS = "tests/test_track_b_b0_2_counts.py" +ARGUMENTS = ["-m", "pytest", "-q", "-rf", "-p", "no:cacheprovider", TESTS] +FAILED = re.compile(r"^FAILED (\S+?\[[^\]]*\]|\S+)") +TIMEOUT = 1800 + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def protocol_sha256() -> str: + text = PROTOCOL.read_bytes() + begin, end = b"", b"" + return sha256(text[text.index(begin) : text.index(end) + len(end)]) + + +def mutate(text: str, replacements) -> tuple[str, list[int]]: + anchors = [] + for replacement in replacements: + old, new = replacement["old"], replacement["new"] + count = text.count(old) + if count == 0: + raise SystemExit(f"anchor missing: {old!r}") + anchors.append(count) + if replacement["occurrence"] == "all": + text = text.replace(old, new) + else: + start = -1 + for _ in range(replacement["occurrence"]): + start = text.index(old, start + 1) + text = text[:start] + new + text[start + len(old) :] + return text, anchors + + +def main(record_path: Path) -> None: + dirty = subprocess.run( + ["git", "status", "--porcelain", "--", str(TARGET)], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + if dirty: + raise SystemExit( + "the count script is modified; run from a clean clone" + ) + specs = json.loads((HERE / "mutation_specs.json").read_text()) + original = TARGET.read_bytes() + original_sha = sha256(original) + environment = dict( + os.environ, + OMP_NUM_THREADS="1", + OPENBLAS_NUM_THREADS="1", + OMP_WAIT_POLICY="PASSIVE", + PYTHONDONTWRITEBYTECODE="1", + PYTHONPATH=f"{ROOT / 'src'}:{ROOT}", + ) + checks = [] + for number, spec in enumerate(specs, 1): + text, anchors = mutate(original.decode(), spec["replacements"]) + started = time.monotonic() + try: + TARGET.write_bytes(text.encode()) + with tempfile.TemporaryDirectory() as storage: + run = subprocess.run( + [sys.executable, *ARGUMENTS], + cwd=ROOT, + capture_output=True, + text=True, + env=dict( + environment, HYPOTHESIS_STORAGE_DIRECTORY=storage + ), + timeout=TIMEOUT, + ) + code, output = run.returncode, run.stdout + run.stderr + except subprocess.TimeoutExpired as error: + stdout = error.stdout + code = "timeout" + output = stdout if isinstance(stdout, str) else "" + finally: + TARGET.write_bytes(original) + restored = sha256(TARGET.read_bytes()) + if restored != original_sha: + raise SystemExit("restore failed") + lines = output.strip().splitlines() + failed = sorted( + {match.group(1) for line in lines if (match := FAILED.match(line))} + ) + status = "killed" if code != 0 else "survived" + checks.append( + { + "mutation": spec["mutation"], + "replacements": [ + dict(replacement, anchor_occurrences=count) + for replacement, count in zip( + spec["replacements"], anchors, strict=True + ) + ], + "exit_code": code, + "failed_tests": failed, + "summary": lines[-1] if lines else "", + "status": status, + "restored": True, + "restored_source_sha256": restored, + "elapsed_seconds": round(time.monotonic() - started, 1), + } + ) + print( + f"{number:2d} {status:8s} {len(failed):3d} {spec['mutation']}" + f" | {checks[-1]['summary']}", + flush=True, + ) + killed = sum(check["status"] == "killed" for check in checks) + record = { + "schema_version": 1, + "verification_class": "structural-count protocol conformance", + "admitted_scope": "Invented frames only; no real-data run.", + "mutation_method": ( + "One deliberate production behavior change per check, from " + "docs/design/track_b_b0_2_evidence/mutation_specs.json. Each " + "check runs the whole count test module in a fresh pytest " + "process with its own Hypothesis database; a nonzero exit " + "kills it. The original source bytes are restored and their " + "SHA-256 verified after every check. No mutated " + "implementation is committed." + ), + "protocol_sha256": protocol_sha256(), + "restored_source_sha256": { + "scripts/track_b_b0_2_counts.py": original_sha + }, + "result": f"{killed}/{len(checks)} mutations killed", + "runner_command": ( + "OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 " + "OMP_WAIT_POLICY=PASSIVE PYTHONDONTWRITEBYTECODE=1 " + ".venv/bin/python docs/design/track_b_b0_2_evidence/" + "mutations.py RECORD" + ), + "test_command": ( + "PYTHONPATH=$PWD/src:$PWD python " + " ".join(ARGUMENTS) + ), + "checks": checks, + } + record_path.write_text(json.dumps(record, indent=2) + "\n") + print(f"FINAL: {record['result']}; restored {original_sha}") + + +if __name__ == "__main__": + main(Path(sys.argv[1])) diff --git a/docs/design/track_b_b0_2_evidence/oracle.py b/docs/design/track_b_b0_2_evidence/oracle.py new file mode 100644 index 000000000..89d914cfe --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/oracle.py @@ -0,0 +1,149 @@ +"""An enumeration oracle for B0.2's disclosure rule. + +INVENTED TABLES ONLY. This module reads no data. It restates the rule in +the protocol by brute force, without the count script's integer programs, +so the searches beside it can check the script's guarantees on tables too +large for the unit tests. + +A table is ``(rows, sizes)``. Each row is a tuple of atom indices, one per +published set, and ``sizes`` holds the true atom sizes. The reader knows +the rows and their coarsened totals, and atoms may be empty, as in the +protocol's reader model. There is one unit type and no registered +relation beyond the rows themselves. +""" + +from __future__ import annotations + +import numpy as np + +BASE = 10 +THRESHOLD = 10 +WIDTH = 4 + + +def coarsen(value: int) -> int: + """Publish 0 as 0, 1 to 9 as ``-1`` (below the base), and round + everything else to the nearest multiple of the base, halves up.""" + if value == 0: + return 0 + if value < BASE: + return -1 + return (2 * value + BASE) // (2 * BASE) * BASE + + +def upper(published: int) -> int: + """The largest count that publishes as ``published``.""" + return max( + value + for value in range(max(published, 0) + BASE + 1) + if coarsen(value) == published + ) + + +def targets(rows, atoms: int) -> list[tuple[int, ...]]: + """Every atom, and every difference of two nested published sets.""" + found = {(atom,) for atom in range(atoms)} + sets = [frozenset(row) for row in rows] + for big in sets: + for small in sets: + if small and small < big: + found.add(tuple(sorted(big - small))) + return sorted(found) + + +def coarsen_array(values: np.ndarray) -> np.ndarray: + """``coarsen`` applied to every element of an integer array.""" + rounded = (2 * values + BASE) // (2 * BASE) * BASE + return np.where(values == 0, 0, np.where(values < BASE, -1, rounded)) + + +def candidates(rows, sizes, limit=None, chunk=1_000_000): + """Every atom-size vector whose publication equals the truth's. + + Vectors are listed in lexicographic order, the first atom slowest, by + decoding consecutive integers in chunks, so memory stays bounded + however many vectors the caps allow. With ``limit``, returns None as + soon as more than ``limit`` vectors are found. + """ + atoms = len(sizes) + published = np.array([coarsen(sum(sizes[a] for a in row)) for row in rows]) + caps = [ + min(upper(published[j]) for j, row in enumerate(rows) if a in row) + for a in range(atoms) + ] + radices = np.array([cap + 1 for cap in caps], dtype=np.int64) + strides = np.ones(atoms, dtype=np.int64) + for a in range(atoms - 2, -1, -1): + strides[a] = strides[a + 1] * radices[a + 1] + total = int(np.prod(radices)) + matrix = np.array([[int(a in row) for a in range(atoms)] for row in rows]) + found = [] + count = 0 + for start in range(0, total, chunk): + index = np.arange(start, min(start + chunk, total), dtype=np.int64) + grid = index[:, None] // strides % radices + keep = np.all(coarsen_array(grid @ matrix.T) == published, axis=1) + found.append(grid[keep]) + count += int(keep.sum()) + if limit is not None and count > limit: + return None + return np.concatenate(found) + + +def classify(values: np.ndarray) -> str: + """Wide if the values span the protection width; otherwise irrelevant + if none lies in 1 to 9, and narrow if one does.""" + if values.size == 0: + raise AssertionError("empty system") + if values.max() - values.min() >= WIDTH: + return "wide" + if not np.any((values >= 1) & (values < THRESHOLD)): + return "irrelevant" + return "narrow" + + +def release(rows, sizes, grid=None, target_list=None): + """Apply the rule by enumeration. + + Returns the status, the targets disclosed in closure rounds, and the + mask of candidate vectors left after the disclosures. + """ + if grid is None: + grid = candidates(rows, sizes) + if target_list is None: + target_list = targets(rows, len(sizes)) + values = {t: grid[:, list(t)].sum(axis=1) for t in target_list} + truth = {t: sum(sizes[a] for a in t) for t in target_list} + mask = np.ones(len(grid), dtype=bool) + disclosed = [] + while True: + narrow = [ + t + for t in target_list + if t not in disclosed and classify(values[t][mask]) == "narrow" + ] + if any(1 <= truth[t] < THRESHOLD for t in narrow): + return "withheld", disclosed, mask + if not narrow: + return "released", disclosed, mask + disclosed.extend(narrow) + for t in narrow: + mask &= ~((values[t] >= 1) & (values[t] < THRESHOLD)) + + +def reader(rows, sizes, grid=None): + """The reader's posterior after a release. + + Returns the candidate grid, the targets, and a mask of the candidate + vectors whose own run of the rule releases. + """ + if grid is None: + grid = candidates(rows, sizes) + target_list = targets(rows, len(sizes)) + released = np.array( + [ + release(rows, list(point), grid, target_list)[0] == "released" + for point in grid + ] + ) + return grid, target_list, released diff --git a/docs/design/track_b_b0_2_evidence/ranges.py b/docs/design/track_b_b0_2_evidence/ranges.py new file mode 100644 index 000000000..210e8c398 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/ranges.py @@ -0,0 +1,73 @@ +"""Check whether a sensitive target's possible sizes leave gaps. + +INVENTED TABLES ONLY. The protocol's rule is a range: a released table's +sensitive targets each take sizes at least 4 apart among the vectors a +reader cannot rule out. It does not promise every size in between. This +script counts, over random released tables, the sensitive targets whose +possible sizes skip a value in that range, and those with fewer than five +possible sizes. Each trial draws two to four atoms, two to six published +rows over them (a repeated row counts once) and atom sizes from 1 to 16, +and keeps the table if every atom has a distinct nonempty signature and +the reader has at most 4,000 candidate vectors. + +Run from the repository root:: + + python docs/design/track_b_b0_2_evidence/ranges.py SEED TRIALS + +The output for seed 5 and 3,000 trials is committed as ``ranges.txt``. +""" + +from __future__ import annotations + +import random +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import oracle # noqa: E402 + + +def main(seed: int, trials: int) -> None: + rng = random.Random(seed) + stats = dict.fromkeys(["released", "sensitive", "gaps", "under5"], 0) + for _ in range(trials): + atoms = rng.choice([2, 3, 3, 4]) + rows = set() + for _ in range(rng.randint(2, 6)): + size = rng.randint(1, atoms) + rows.add(tuple(sorted(rng.sample(range(atoms), size)))) + rows = sorted(rows) + signatures = [ + frozenset(j for j, row in enumerate(rows) if a in row) + for a in range(atoms) + ] + if not all(signatures) or len(set(signatures)) != atoms: + continue + sizes = [rng.randint(1, 16) for _ in range(atoms)] + grid = oracle.candidates(rows, sizes, limit=4000) + if grid is None: + continue + target_list = oracle.targets(rows, atoms) + status, disclosed, mask = oracle.release( + rows, sizes, grid, target_list + ) + if status != "released": + continue + stats["released"] += 1 + for t in target_list: + if not 1 <= sum(sizes[a] for a in t) < oracle.THRESHOLD: + continue + stats["sensitive"] += 1 + values = sorted(set(grid[mask][:, list(t)].sum(axis=1).tolist())) + if len(values) != values[-1] - values[0] + 1: + stats["gaps"] += 1 + print("GAP", rows, sizes, t, values, disclosed) + if len(values) < 5: + stats["under5"] += 1 + print("UNDER5", rows, sizes, t, values, disclosed) + print(stats) + + +if __name__ == "__main__": + main(int(sys.argv[1]), int(sys.argv[2])) diff --git a/docs/design/track_b_b0_2_evidence/ranges.txt b/docs/design/track_b_b0_2_evidence/ranges.txt new file mode 100644 index 000000000..3175515fb --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/ranges.txt @@ -0,0 +1 @@ +{'released': 1635, 'sensitive': 2665, 'gaps': 0, 'under5': 0} diff --git a/docs/design/track_b_b0_2_evidence/search.py b/docs/design/track_b_b0_2_evidence/search.py new file mode 100644 index 000000000..5d8585e16 --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/search.py @@ -0,0 +1,117 @@ +"""Random search of larger tables against the protocol's guarantees. + +INVENTED TABLES ONLY. Each trial draws three to five atoms and three to +six published rows over them (a repeated row counts once), then a size for +each atom from ``SIZES`` (1 to 14) or zero. It keeps the table if no atom +is empty, every atom has a distinct nonempty signature, and the reader has +at most 6,000 candidate vectors. For each released table it checks, by +enumeration with ``oracle``: + +- the replay theorem: the reader's posterior (every candidate whose own + run of the rule releases) equals the candidates left after the rounds; +- the width guarantee: every target of true size 1 to 9 spans at least + the protection width over that posterior; +- the closure rounds' necessity: whether a rule that stopped after its + first round would leave a sensitive target narrow. + +Run from the repository root:: + + python docs/design/track_b_b0_2_evidence/search.py SEED TRIALS + +The output for seed 2 and 1,500 trials is committed as ``search.txt``. +""" + +from __future__ import annotations + +import random +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import oracle # noqa: E402 + +SIZES = [1, 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 14] + + +def single_round(rows, sizes, grid, target_list) -> bool: + """Whether a rule with no closure round releases this vector.""" + values = {t: grid[:, list(t)].sum(axis=1) for t in target_list} + narrow = [t for t in target_list if oracle.classify(values[t]) == "narrow"] + return not any( + 1 <= sum(sizes[a] for a in t) < oracle.THRESHOLD for t in narrow + ) + + +def narrow_sensitive(sizes, grid, mask, target_list): + """The sensitive targets that span less than the protection width.""" + found = [] + for t in target_list: + if 1 <= sum(sizes[a] for a in t) < oracle.THRESHOLD: + values = grid[mask][:, list(t)].sum(axis=1) + if values.max() - values.min() < oracle.WIDTH: + found.append((t, sorted(set(values.tolist())))) + return found + + +def main(seed: int, trials: int) -> None: + rng = random.Random(seed) + stats = dict.fromkeys( + [ + "tables", + "released", + "multi_round", + "theorem_fail", + "width_fail", + "single_unsound", + ], + 0, + ) + for _ in range(trials): + atoms = rng.choice([3, 3, 4, 4, 5]) + rows = set() + for _ in range(rng.randint(3, 6)): + size = rng.randint(1, atoms) + rows.add(tuple(sorted(rng.sample(range(atoms), size)))) + rows = sorted(rows) + signatures = [ + frozenset(j for j, row in enumerate(rows) if a in row) + for a in range(atoms) + ] + if not all(signatures) or len(set(signatures)) != atoms: + continue + sizes = [rng.choice([0, *SIZES]) for _ in range(atoms)] + if 0 in sizes: + continue + grid = oracle.candidates(rows, sizes, limit=6000) + if grid is None: + continue + grid, target_list, released = oracle.reader(rows, sizes, grid) + stats["tables"] += 1 + status, disclosed, mask = oracle.release( + rows, sizes, grid, target_list + ) + if status != "released": + continue + stats["released"] += 1 + stats["multi_round"] += bool(disclosed) + if not np.array_equal(released, mask): + stats["theorem_fail"] += 1 + print("THEOREM FAIL", rows, sizes) + for found in narrow_sensitive(sizes, grid, released, target_list): + stats["width_fail"] += 1 + print("WIDTH FAIL", rows, sizes, found) + single = np.array( + [single_round(rows, list(p), grid, target_list) for p in grid] + ) + found = narrow_sensitive(sizes, grid, single, target_list) + if found: + stats["single_unsound"] += 1 + print("SINGLE-ROUND NARROW", rows, sizes, found[0]) + print(stats) + + +if __name__ == "__main__": + main(int(sys.argv[1]), int(sys.argv[2])) diff --git a/docs/design/track_b_b0_2_evidence/search.txt b/docs/design/track_b_b0_2_evidence/search.txt new file mode 100644 index 000000000..782d943aa --- /dev/null +++ b/docs/design/track_b_b0_2_evidence/search.txt @@ -0,0 +1 @@ +{'tables': 562, 'released': 531, 'multi_round': 0, 'theorem_fail': 0, 'width_fail': 0, 'single_unsound': 0} diff --git a/docs/design/track_b_b0_2_exclusions.json b/docs/design/track_b_b0_2_exclusions.json new file mode 100644 index 000000000..ef415dce0 --- /dev/null +++ b/docs/design/track_b_b0_2_exclusions.json @@ -0,0 +1,52 @@ +{ + "added": "2026-10-23", + "q6_exclusions": [ + { + "lines": null, + "path": "docs/design/track_b_b0_2_counts.json", + "scope": "whole_file" + }, + { + "lines": null, + "path": "docs/design/track_b_b0_2_counts_mutations.json", + "scope": "whole_file" + }, + { + "lines": null, + "path": "docs/design/track_b_b0_2_counts_report.txt", + "scope": "whole_file" + }, + { + "lines": null, + "path": "docs/design/track_b_b0_2_exposure_inventory.json", + "scope": "whole_file" + }, + { + "lines": null, + "path": "docs/design/track_b_b0_2_landscape.md", + "scope": "whole_file" + }, + { + "lines": null, + "path": "docs/design/track_b_b0_2_plan.md", + "scope": "whole_file" + }, + { + "lines": null, + "path": "docs/design/track_b_b0_2_protocol.md", + "scope": "whole_file" + }, + { + "lines": null, + "path": "scripts/track_b_b0_2_counts.py", + "scope": "whole_file" + }, + { + "lines": null, + "path": "tests/test_track_b_b0_2_counts.py", + "scope": "whole_file" + } + ], + "reason": "The dynamics coordinator separated B0.2 from the B2 successor design on 2026-10-21: successor-design roles may not read B0.2's work (PR #554 beyond its title). These are B0.2's files, including the structural counts and exposure inventory it will add; the counts must be listed here before they reach master. No exposure-inventory scan lists them, so this standing record excludes them from every blinded review root that scripts/track_b_b0_1_review_root.py builds.", + "schema": "track_b_b0_2_exclusions.v1" +} diff --git a/docs/design/track_b_b0_2_landscape.md b/docs/design/track_b_b0_2_landscape.md new file mode 100644 index 000000000..fffdbf9d3 --- /dev/null +++ b/docs/design/track_b_b0_2_landscape.md @@ -0,0 +1,344 @@ +# Track B B0.2: landscape + +**Status: synthesis of five landscape lanes, 2026-10-23, revised +2026-10-24 for public HRS. It computes nothing and freezes nothing.** It feeds B0.2's structural-audit +protocol, which is frozen before any count, and the questions for Max. + +Five read-only lanes ran on 2026-10-21 and 2026-10-22, as set out in the +B0.2 plan (`docs/design/track_b_b0_2_plan.md`, section 3, step 1): + +| Lane | Subject | Output | +|---|---|---| +| a1 | Repository and staged-data inventory | `lanes/a1-repo-inventory-out.md` | +| a2 | Microdata sources and access | `lanes/a2-microdata-access-out.md` | +| a3 | Public aggregate and administrative sources | `lanes/a3-aggregate-sources-out.md` | +| a4 | Exposure catalogue for each gate's candidate holdouts | `lanes/a4-exposure-catalog-out.md` | +| a5 | Per-gate requirements and J's joint record | `lanes/a5-gate-requirements-out.md` | + +The outputs are in +`microcosm-launch-evidence/dynasim-parity-20260909/trackb-b0-2-20261021/`. +No lane read survey microdata, computed a count or ran a script. Where a +lane could not open a source page, it marked the fact **UNVERIFIED**, and +this synthesis keeps that label. + +## 1. What each gate can draw on now + +**Staged and readable on this machine** (a1, section 1): +- PSID family files 1968-2023, the cross-year individual file, the + marriage history, childbirth history and relationship matrix; +- SSA's 2006 Earnings Public-Use File (EPUF), hash-pinned; +- committed SSA, Trustees, CBO, NCHS and DI ASR tables under + `data/external/`. + +**Not staged:** SIPP, CPS ASEC and CPS tenure files. **Absent:** any HRS +file or HRS loader, and any reader for SSA's Benefits and Earnings +Public-Use File (BEPUF), the Master Beneficiary Record or another +SSA-linked file. + +| Gate | What exists | What is missing | +|---|---|---| +| B3 | Head and spouse earnings at all ages from the family files; odd income years; EPUF through 2006; B2's and M6's cell and support helpers | A cell family for ages 62-69; other family members' earnings; an HRS loader if B3 uses HRS; evidence of an unused partition | +| B3L | The panel through income year 2022; boundary and origin already parameters | A 2006-origin domain reaching ages 62-69; everything B3 lacks | +| B4 | Marriage histories and transitions; candidate 16's port; roster-only widowhood; the DI engine; self-reported disability; deaths by year; forward support | A person-level administrative DI holdout; deaths separated from attrition; widowhood linked to the spouse's death; entrants | +| I's timing gate | Marriage months; DI award year; aggregate tables by month of entitlement; a SIPP job-month reader (data not staged) | Dated person-level death, award, claim and conversion events; a monthly-receipt loader; an event journal | +| B5-empirical | R1, R2 and R3 statutory modules; annual PSID Social Security amounts | Any withholding or deduction observation; RET-countable earnings | +| B6a | A marginal claiming distribution; first observed receipt year; receipt history; years of coverage | Insured status; an insured, unclaimed person-month risk set with entitlement months | +| B6b | The FRA schedule and B6a's pieces | Identifying policy variation, and B6a's gaps | +| J | Self-reported benefit types; opening status; spouse links; the G layer; exposed aggregate SSA tables | Estimands and limits; a holdout independent of B2; a joint truth builder; administrative payment and recipient denominators | + +**External candidates** (a2, a3), all still to be chosen: + +| Source | Strongest use | Access | Status | +|---|---|---|---| +| HRS public files and the RAND HRS longitudinal file | B3 at ages 62-69; B4 deaths separated from attrition; self-reported claim and DI dates for I; joint states for J | Registration; use follows the project's data-access record | Available for the estimands it supplies (2026-10-24); pages refused automated fetches | +| HRS files linked to SSA records | Administrative earnings, insured status and entitlement dates for B3, B6a and I | Virtual-desktop enclave run by a named person; 6-8 week decision (search snippet); likely an institutional sponsor and IRB | UNVERIFIED | +| SIPP public-use panels | Monthly receipt, earnings and marital status | Public download | Variables verified through the Census API | +| SIPP Synthetic Beta, validated on the Gold Standard File | Insured status and claim timing for older cohorts, to 2014 | Application approved in about five business days; real results only through Census validation | Verified on census.gov | +| FSRDC files linking SIPP, CPS or ACS to SSA records | Everything, including possible deduction fields | Person with Special Sworn Status on site; 6-12 months; $15,000-20,000 a year per project | Verified on the SAP catalog and RDC pages | +| BEPUF 2020 | Structural and recipient-side sanity checks only | Public CC0 download | **Fully synthetic** (section 5): not observed data, so not a holdout for J, B5-empirical or I | +| Disability Analysis File | Monthly disability participation, with birth and death dates | "Restricted public" | Access UNVERIFIED | +| SSA Supplement, DI ASR, OACT series, Monthly Snapshot | Aggregate stocks, flows, awards, withholding (6.E) | Public | Section and table indexes verified; several sections already exposed (section 2) | +| NCHS life tables, mortality files and HMD | B4 mortality | Public; HMD needs registration | HMD and NCHS share vital-registration inputs, so they are not independent of each other | +| ACS and CPS marital and earnings tables | B4 state shares; B3 employment margins | Public | Metadata verified | + +BEPUF 2020 was the strongest public candidate for J. It is fully +synthetic (section 5), which leaves these gaps. + +**Gaps no verified public source closes:** +- the insured, unclaimed person-month risk set (B6a); +- observed annual deductions and withholding (B5-empirical); +- a complete joint holdout for J. + +Each of these needs either a restricted linked source or the design's +narrower fallback: +- B6a: a marginal claiming distribution, under `NO_RISK_DENOMINATOR`; +- B5: statutory conformance and sensitivities only; +- J: `JOINT_VALIDATION_FAIL` or a declared narrower scope. + +## 2. Exposure + +**Prior uses recorded by a4.** Each gate's candidate data has already +been used as follows. None of these can be an unused holdout (design +section 5.2). + +- **M6 candidates and floors:** + - PSID earnings reference years 2016 and 2018, anchored 2014; + - flows 2015-2019; + - diagnostics on earnings 2020 and 2022 and flows 2020 and 2021; + - cohorts 25-44 and 45-64. + + These bear on B3 (its 62-64 band), B3L, B4 and J. +- **The q\* and ρ\* selectors:** pseudo-boundaries 2006, 2008 and 2010, + scored on reference years b, b + 2 and b + 4 at ages 25-64. These bear + on B3L and J. +- **B0.1's protocol v2:** fitted at origin 2006 and projected to 2008 + and 2010, ages 25-64, with derived summaries published. This bears on + B3L's 62-64 band. +- **Marital selectors:** first marriage and remarriage at boundaries 2006, + 2008 and 2010. This bears on B4 and J. +- **M4 disability floors:** PSID waves through 2023, ages 20-66. +- **Mortality floors:** waves 1969-2023, ages 25-85+. +- **The DI-entitlement diagnostic:** validated against the DI ASR 2023. +- **The claiming reference:** fitted 1998-2019, evaluated 2020-2022. +- **EPUF and `gate_epuf_fill`:** earnings 1951-2006, ages 22-74, with DEV + and TEST scores published. +- **B2:** PSID income years 2012 and 2014, cohorts 25-44 and 45-64; gated + p90 and zero-earnings rate. +- **The SSA Supplement:** design participants opened 2023 §6.B, 2024 §4.B + and §6.E, and 2025 §6.E, including numeric tables (design lines + 290-297). +- **SSA tables committed in the repository:** a1 adds those not on the + design's list: + - Supplement 6.B for 2014, 2025 and 2026; + - the 2025 4A, 4B, 5A and 6A snapshots; + - 2008 4C; + - DI ASR tables for 2007, 2008 and 2023; + - 2024 Supplement captures staged under + `psid-data/legal-capture-staging/`. + +The held-out data registry (PR #560, +`docs/design/track_b_holdout_registry.json`) records the scored gates +among these. B0.2's mechanical exposure inventory completes it. + +**HRS exposure is UNVERIFIED.** No executed HRS exercise was found, but +issue and PR threads were not searched. + +**Accidental exposures during the lanes.** Each lane recorded them. No +value was copied into any output. +- a1 and a2: source files print microdata or codebook counts (for + example `deaths.py`'s codebook tallies and `epuf.py`'s row counts). The + lanes read them and copied nothing. +- a3: four web pages showed numbers before the lane stopped reading: + - the Monthly Statistical Snapshot for September 2026; + - the DI ASR 2025 highlights; + - an OACT chart's alternative text; + - an NSFG methods page. + + A numeric erratum also appeared on the 2024 Supplement contents page, + and some search snippets showed outcomes. +- a4: a content search with no exclusions (`git grep -il HRS`) read the + restricted B2 outcome files' bytes. It displayed file names only. + Masking also failed on several files, showing outcome counts or figures: + - `docs/amendments/gate_epuf_fill_registration_proposal.md`; + - `docs/design/di_entitlement.md`; + - comments in `harness/m6_cells.py`; + - assertions in `scripts/build_m6_holdout_floors_v4.py`. +- a5: excerpts of the B0.1 audit and addendum showed planning and power + figures, and `b2.py:229-235` showed inherited outcome text. + +These lanes are author-side landscape roles, not blinded reviewers, so +none can serve as J's blinded reviewer. B0.2's exposure record carries +each incident. The exposed web pages (the Snapshot for September 2026 +and the DI ASR 2025 highlights) are exposure for any gate that would use +them. + +**What the mechanical inventory must add** (a4, section "A B0.2 +mechanical inventory"): +- **A candidate registry per gate:** source, reference year, interview + wave, event period, age, partition, denominator, concept and estimand. +- **Exclusions first:** apply them before any content is read. +- **Pattern families:** B0.1's families, plus families for earnings, + marital events, disability, mortality, claiming, RET, dates and joint + states. +- **Classification of each artifact,** separately by evidence content + and by role. Content is one of names or rules, observed values, + projected values, derived values, transient computation, or + unverified. Role is one of training, selection, floor, development, + diagnostic, regression or holdout. +- **Integers and code in scope:** cover hard-coded counts and code, not + only decimals. +- **An off-repository register** supplied by outcome-aware holders, for + issue and PR threads and external directories. + +## 3. Requirements per gate + +a5 sets out, for each gate, the ten section 5.2 elements and the power +procedure. For each element it gives what the design fixes, what B0.1's +rulings carry over, and the open options with a recommended default. +Its summary table marks almost every element of every gate "open: needs +Max" or "open: needs counts". Only exposure history is "carried over". + +Its main recommendations: +- **A common power procedure,** for Max's approval: prospective + whole-gate equivalence power. The operating characteristic is the + probability that a faithful candidate passes the entire registered + conjunction, evaluated over a frozen grid of nuisance assumptions, with + a conservative dependence treatment and finite-design and estimation + uncertainty included. +- **Permissible structural counts and forbidden outcome disclosures,** + per gate. a5's table "What the structural-audit protocol should allow + and forbid" becomes the protocol's core. Missingness and availability + flags can reveal outcomes (a "date available" flag can identify an + event), so the protocol must test that no permitted output reconstructs + a target truth cell. B0.1's death-disposition counts were structural + for B2 but would reveal part of B4's mortality surface. +- **J:** six candidate joint estimands (J1-J6): + - J1, a common-date earnings, claiming and DI table; + - J2, the earnings-claiming association by DI history; + - J3, joint transition pathways; + - J4, recipients and payment within joint states; + - J5, survivor-path cumulative incidence; + - J6, post-bereavement payment persistence. + + Each has limit options drawn from the design's styles (2-3 percentage + points for probabilities, 5-10% for amounts). a5 also states what + blinded ratification must certify. +- **B2's medians:** ask Max whether to assign a separately named + cohort-median component to B3, with matching regression cells in B3L, + or to leave medians unadmitted. The current p50 is a weighted + positive-earner median. No gate holds ages 25-61 earnings to a fixed + limit (`b2.py:215-216`). +- **The single §5.3 feasibility review for B3 and B3L:** its inputs and + procedure are set out (a5, final section). It uses B0.1 audit section + 11.3's figures only through a cleared review process. + +**H's start roster** (a1, section 4). No H specification exists. Three +rosters are candidates: +- B2's 2010 head-and-spouse domain, defined in code; +- the full 2011 anchor roster; +- Track A's 2011 closed cohort (`cohorts/psid2010.py`). + +Each changes B4's coverage declaration. The choice is a question for Max +or for H's design, and the count runs under B0.2's protocol. + +## 4. Pricing of the design's still-unpriced items + +These are estimates in engineer-days (d) of reviewed output, or calendar +time, with their basis in the cited lane: + +| Item (design §3.3) | Access | Loader and gate work | Source | +|---|---|---|---| +| HRS loader and access, public | About one week of registration; use follows the project's data-access record | Loader 4-8 d; B0.2 adds 1-2 d to freeze HRS elements per gate | a1 4-7 d, a2 5-8 d | +| HRS linked to SSA records | 2-4 months, about 6 if a sponsor is needed; 1-3 weeks of disclosure review per output | 6-10 d by a person in the enclave | a2 | +| Linked withholding data (B5-empirical) | Public: none, if BEPUF 2020 carries a deduction field (UNVERIFIED). Otherwise HRS-SSA (2-4 months) or an FSRDC (6-12 months, $15,000-20,000 a year) | 1 d documentation check; then 8-12 d to rebuild annual deductions; 4-6 d to freeze and run | a2 | +| I's timing gate: data, loaders, gate | Public HRS and SIPP: about one week; restricted fallback as above | SIPP extension 3-5 d; BEPUF 3-4 d; freezing estimands, limits and power 3-5 d; running the gate 4-6 d. About 10-20 d beyond B0.2's own 5-7 d | a1, a2 | +| Entrant initialization | Public ACS, CPS and EPUF | **Cited from the open-population lane: OP8, 25-39 d; 34-52 d with the demographic packets** (draft, PR #557 at `2acd0bed`, pending its review) | `inputs/entrant-initialization.md` | +| B6b access | Public HRS about one week; restricted 2-4 months; FSRDC 6-12 months | Shared with the HRS rows | a2 | +| 2032 and 2050 horizons | — | Not re-priced here. Design B priced its B8 at 40-70 d, for scale only (design line 203). The open-population lane prices its economic horizon separately (OP10b, OP11b; draft) | design; #557 | + +The insured-status rule that B6a, entrant initialization and OP12 need is +on the rules lane under d1802. a1's 2-3 d estimate for it is for scale +only. + +## 5. What must be verified before the protocol relies on it + +These are facts the lanes could not open. Verify them in a browser, +which works where automated fetches are refused, and open no numeric +table: +1. **BEPUF 2020: settled, 2026-10-23.** SSA's landing page + (https://www.ssa.gov/policy/docs/microdata/bepuf-2020/index.html, opened + in a browser; it showed no outcome figures) describes the file as "fully + synthetic microdata records statistically representing a self-weighted + 10 percent sample of the current adult OASDI beneficiary population as + of December 2020". It says each record's earnings history and benefit + credited are related "by statistical modeling rather than by applying + Social Security program rules", and cautions against using them + together for individual-level analysis. A synthetic file is not + observed data, so it cannot serve as an observed holdout for J, + B5-empirical or I's timing gate, whatever its fields. The user guide + PDF did not render as text in the browser, and its field list is + unread; nothing above depends on it. +2. **HRS.** Public HRS is settled (2026-10-24): it counts as available + for the estimands it supplies, and its use follows the project's + data-access record. Restricted HRS-SSA access, its eligibility and + lead time remain to be verified. +3. **The Disability Analysis File's access route.** +4. **Two staged files that may be restricted** (a1, section 7). Do not + open them until someone rules: + - `psid-data/validation-sources/urban-favreault2007-311436.pdf`; + - the `ssa-mint-*` captures. + +## 6. What the protocol can count, and what waits for Max + +PSID is the only staged source whose structure B0.2 can count now. So +protocol version 1 covers structural counts on the staged PSID files for: +- B3: support at ages 62-69 by wave, households and design units; +- B3L: the 2006-origin roster and later availability; +- B4: the opening roster, linkage coverage and design fields; +- H's candidate rosters; +- J: PSID-side linkage coverage outside B2's evaluation households. + +It applies a5's forbidden-disclosure lists. Gates with no staged source +(I, B5-empirical, B6a, B6b) get a source decision first. Their protocol +sections follow once Max chooses sources and the access questions above +are answered. + +Questions for Max, gathered from a2 and a5, go to him through +`cos add-decision` when the records are written. They include: +- the common power procedure; +- each gate's source; +- whether to pursue restricted HRS-SSA or FSRDC access; +- H's roster; +- B2's medians; +- J's estimands and limits; +- the percentage-error conventions. + +## 7. A decision needed now: restricted linked data + +Design Q4 (line 606, approved in d515) sets the order: evaluate public +HRS and SSA sources first, and request restricted linked data "only if +B0 shows that public sources cannot supply the risk denominator, the +timing observations or the withholding observations". + +The landscape shows that condition is met for three of those needs: +- **No verified public source supplies B6a's risk denominator:** an + insured, unclaimed person-month risk set with entitlement dates. + Public HRS does not observe insured status (a2, section 2.1). +- **No verified public source supplies observed withholding or annual + deductions for B5-empirical.** Supplement table 6.E4 is a December + status diagnostic (design line 224), and BEPUF 2020 is synthetic + (section 5). +- **No verified public source supplies J's joint holdout,** with + observed payment and recipient denominators. + +Restricted access is slow: +- HRS linked to SSA records: about 2-4 months, through a named + person's virtual desktop in an enclave, probably with an institutional + sponsor (UNVERIFIED); +- an FSRDC: 6-12 months and $15,000-20,000 a year per project, with a + person holding Special Sworn Status working on site (verified on the + RDC pages). + +These lead times would fall on the path to B6a and J, and so to B7. +On both routes a person does the analysis inside the enclave or data +center. + +The options: +1. **Apply for HRS-SSA access now,** in parallel with B0.2. It supplies + insured status, entitlement dates and administrative earnings. It + leaves B5's withholding UNVERIFIED (it depends on the benefit file's + detail). +2. **Apply for an FSRDC project now.** This is the only verified route to + Master Beneficiary Record payment data. Whether that data carries + work-deduction fields is UNVERIFIED. +3. **Both.** +4. **Neither, and accept the design's fallbacks:** + - B5 publishes statutory conformance and sensitivities only; + - B6a keeps a marginal claiming distribution (`NO_RISK_DENOMINATOR`); + - J is narrowed to what PSID or public HRS can observe, or stays + blocked, and B7 is limited accordingly. + +Public HRS is needed under every option. It now counts as available +for the estimands it supplies (2026-10-24), and its use follows the +project's data-access record. Which estimands those are is fixed in +the J and B6a records, before any HRS file is read. diff --git a/docs/design/track_b_b0_2_plan.md b/docs/design/track_b_b0_2_plan.md new file mode 100644 index 000000000..2e1505cd6 --- /dev/null +++ b/docs/design/track_b_b0_2_plan.md @@ -0,0 +1,163 @@ +# Track B B0.2: plan + +**Status: plan, 2026-10-21. This is not an audit record, and no gate can +register on it.** Max ruled in d1802 (2026-10-21) that B0.2 starts now, +on the Track B lane, after the B2 successor memo. This file sets out what +B0.2 must deliver, the order of work and the rules that keep it apart +from the successor design. It changes as the work goes; the audit +records that follow it are the frozen artifacts. + +"The design" means +`microcosm-launch-evidence/dynasim-parity-20260909/trackb-design-20260928.md` +(revision 3), which sits outside this repository. Line numbers refer to +that file. + +## 1. What B0.2 must deliver + +- **One frozen record per gate** (design line 288): B3, B3L, B4, I's + observational timing gate, B5-empirical RET, B6a, B6b and J. Each + record lists the ten §5.2 elements, read as B0.1 read them + (`docs/design/track_b_b0_1_audit.md`, section 2): source release, + variables, population, exposure history, holdout partition, critical + cells, numerical tolerances, uncertainty treatment, pass conjunction, + and reference dates kept separate from interview and publication + availability. Each record also names its power procedure. A gate + cannot register until its record is frozen. +- **A structural-audit protocol frozen before any count or power + summary** (line 286). The record keeps reference dates apart from + availability. Outcomes that were already inspected stay regression + evidence, and "later" or "external" does not by itself establish an + unused holdout. +- **A blinded review of prospective feasibility and power** for the + §5.3 limits (line 120). It may find a limit infeasible before any + candidate result, which leads to prospective rescoping; it may never + loosen a limit after candidate results (line 308). +- **J's joint estimands and numerical limits**, frozen in B0.2 and + ratified by a blinded reviewer before B3 registers (lines 230, 320, + 365-368; Q10, line 615). J's holdout must be independent of B2's + evaluation data. +- **Exposure.** B2's result is recorded as exposed before J's holdout is + chosen (lines 297, 368). +- **The held-out data registry.** `docs/design/track_b_holdout_registry.json` + (PR #560, on its own so that every lane can cite it on master) is the + single registry of held-out years, editions, tables and partitions for + every Track B gate and every open-population gate (coordinator, + 2026-10-21). J's candidates are registered there first; the + open-population lane's OP0.2 registers its picks for OP12 before it + freezes them; no unit is held out by two gates silently. B0.2's + mechanical exposure inventory, `docs/design/track_b_b0_2_exposure_inventory.json`, + completes it. That inventory must cover every gate with a committed + scored run, not only the five the registry seeds. #560's round-4 + review found four more with committed scored outputs: `gate_1`, + `gate_2`, `gate_2b` and `gate_2c`. Each becomes an authorized + historical import in the registry module, or is recorded as holding + out nothing that a Track B or open-population gate could use. +- **Pricing of the still-unpriced items** (§3.3, line 198; Q13, line + 618): data access, loaders and gate work for I's observational timing + gate; the HRS loader and access; linked-withholding data access and + loaders for empirical RET; entrant initialization; B6b's access time; + and the 2032 and 2050 horizon extensions. Q4 (line 606) sets the order + for data: public HRS and SSA sources are evaluated first, and + restricted linked data are requested only if public sources cannot + supply the risk denominator, the timing observations or the + withholding observations. +- **Projected years past 2030.** The open-population design, approved + as recommended in d1925 (2026-10-23; PR #557, its question 5), sets + this principle: every projected year past 2030 is published as + conditional on named assumptions, with drift diagnostics, and never + as a validated forecast, and the DYNASIM comparisons at 2050, 2065 + and 2070 are registered, report-only and blind. It bears on B7's + choice of target years and on J's record. +- **B0.1's handoffs** (`docs/design/track_b_b0_1_addendum.md`, "To + B0.2"): + - the marital selectors' pseudo-boundaries 2008 and 2010 bear on B4; + - B3L's 2006 boundary is a q\* and ρ\* pseudo-boundary whose floors + and outcomes are in the ledgers, and protocol v2 projected candidate + 3's law from 2006 to 2008 and 2010 at ages 25-64, which includes + B3L's 62-64 band; + - §5.3 feasibility is reviewed once, for B3 and B3L, with B0.1's + section 11.3 figures as inputs; + - H's spec names and counts its 2010 start roster, and B4's coverage + declaration cites it; + - the addendum's design rule (its section 16, item 10: household + resampling stands only if every cell's design-ratio 95th percentile + is at most 1.10) did not hold at the 2006 pseudo-origin, so the + choice between inflating standard errors and resampling design + clusters arises again for every later gate. +- **B2's medians.** Under d1606 option C1, B2 gates the 90th percentile + and the zero-earnings rate by cohort; its two median cells are + report-only (`src/populace_dynamics/track_b/b2.py`, `GATE_SURFACE` and + `HEADLINE_SCOPE`). B0.2 records which later gate, if any, covers the + medians, and states that nothing downstream may rely on B2 for them. + +## 2. Separation from the successor design + +Under d1659 a B2 successor is designed on evidence independent of B2's +outcome. Its design inputs live in +`microcosm-launch-evidence/dynasim-parity-20260909/trackb-b2-successor-20261018/`. +B0.2 and that lane must not see each other's evidence. + +- No B0.2 lane or reviewer reads the successor folder, or anything + derived from it. B0.2's own working files live in + `microcosm-launch-evidence/dynasim-parity-20260909/trackb-b0-2-20261021/`, + and its lanes run in a standalone clone with the design and + `RESTRICTED-FILES.md` copied in, and nothing else from the evidence + directory. +- The successor lane must not read B0.2's counts, planning values or + records, or B0.2's working folder. Before any B0.2 file holding + real-data counts reaches master, B0.2 adds an exclusion inventory in + the format `scripts/track_b_b0_1_review_root.py` reads + (`q6_exclusions`), which successor-lane review roots name. +- B2's outcome is not restricted for B0.2. The 2026-10-18 entry in + `RESTRICTED-FILES.md` restricts B2's outcome files for roles building + or proposing a successor blind, and B0.2 must record that exposure. + B0.2's blinded roles still get roots built by the review-root + builder, which always drops the standing B2-outcome record + (`docs/design/track_b_b2_outcome_exclusions.json`); they read B2's + exposure from B0.2's own record. + +## 3. Order of work + +1. **Landscape (no counts).** Separate lanes, none of which sees + another's output before synthesis: + - a repository and staged-data inventory: loaders, staged sources + and what each gate needs against what exists, from listings and + code only, with no survey values read; + - external sources and access: public and restricted HRS, SIPP + public-use and SSA-linked files, SSA public-use files, sources of + withholding and event-timing observations, and inputs for entrant + initialization, each with access steps, lead time, cost and the + gates it could serve; + - a mechanical exposure inventory for the candidate holdouts of each + gate, extending B0.1's inventory approach; + - a per-gate requirements table drawn from the design text. +2. **Protocol.** One B0.2 structural-audit protocol, reviewed by GPT-6.1 + Sol (`--tier hard`), then committed and hash-pinned before any count + runs, as B0.1's section 16 was. +3. **Counts and power.** One-shot count scripts, run once from a clean + worktree at the protocol commit, writing exclusive outputs. Tests + include value-invariance tests in the style of + `test_counts_are_invariant_to_valid_earnings_values`, Hypothesis + property tests and mutation checks. +4. **Records.** The per-gate records, the pricing table and questions + for Max with recommended defaults, queued with `cos add-decision`. +5. **Reviews.** A Sol review of each record; the blinded + feasibility-and-power review; the blinded ratification of J's record + before B3 registers. + +The work may split into several pull requests (plan and protocol, +counts, records). This draft pull request opens with the plan. + +## 4. Rules + +- Design §5.1: no edits to `engine/loop.py`, `engine/steps.py`, + `gates.yaml` or committed `runs/*.json`. +- B0.2 fits no model, runs no projection and computes no candidate + outcome. What it may compute from real data is fixed by its protocol. +- Local runs use at most four BLAS and OpenMP threads with + `OMP_WAIT_POLICY=PASSIVE`, and at most four test workers. +- Any change to `tests/tier_counts.json` waits for a merge slot from the + Microcosm dynamics coordinator. +- B0.2's 5-7 engineer-day estimate (line 120) predates the timing gate + and is not re-priced in the design (line 201). B0.2 reports its actual + effort beside that estimate. diff --git a/docs/design/track_b_b0_2_protocol.md b/docs/design/track_b_b0_2_protocol.md new file mode 100644 index 000000000..e568a31cb --- /dev/null +++ b/docs/design/track_b_b0_2_protocol.md @@ -0,0 +1,634 @@ +# Track B B0.2: structural-audit protocol + +**Status: version 1, DRAFT, 2026-10-24. Not yet frozen. No count has run.** + +Design section 5.2 (line 286) says B0's structural-audit protocol is +frozen before any count or power summary is computed. This file is that +protocol for B0.2. Version 1 covers only what the staged PSID can +answer. It is the first count step the landscape synthesis identifies +(`docs/design/track_b_b0_2_landscape.md`, section 6). + +A later version, recorded as such, will cover the other sources (HRS, +SIPP, SSA tables) once Max chooses them. + +The counting script `scripts/track_b_b0_2_counts.py` and its tests +arrived in `be7d4f42`. The unit test `tests/test_track_b_b0_2_counts.py` +pins the block's SHA-256, taken over the text from the begin marker +through the end marker. Adding the script did not freeze the protocol. +The freeze is a later commit that changes only the status line above +to FROZEN and states the pinned SHA-256. It follows an independent +review of the block and the script that leaves no blocking finding. The +script then runs once, from a clean worktree at that commit. If the +review changes the block, the pin and the script change with it before +the freeze. + +Review round 1 (Sol, at `ec9eb486`) asked for changes, and the block was +revised before any freeze: +- **Ages:** a known-age definition, so a missing age (999) is in no band + and not in the Track A proxy. +- **Disclosure:** coarsening and an integer-programming audit, which + replace subtraction-based complementary suppression, after two + executed recoveries of small cells. +- **One run:** a frozen-status check and an attempt record. + +Review round 2 (Sol, at `0644b717`) asked for changes again, and the +block was revised before any freeze: +- **Audit metadata:** the committed file had reported how many targets + were audited, which an executed fixture used to narrow a remainder to + four sizes. The disclosure block now holds only the base, the rules + and the status. No program the audit runs depends on a true size, so + a reader can rerun it, and every target it finds narrow is treated as + disclosed. +- **Intervals:** a published multiple stands for max(B, m − B/2) to + m + B/2 − 1, matching the coarsening rule. +- **Writes:** both files are written to temporary names, flushed and + linked into place, so a failure leaves no partial file. +- **Nonblocking findings, also answered:** relations across unit types + are part of the audit's model; a single fixed base replaces the + ladder; a target is wide on two verified points, without a proven + optimum; and tests now cover a fractional age of 62.5, negative + inputs to coarsening, the solver's exact-optimum setting and the + attempt record's flush. + +Before round 3, three sentences of the block were corrected. They had +said a wide target keeps at least five possible sizes; the rule, and the +tests, guarantee a range of width at least 4. The evidence for the +revision (an enumeration oracle, searches, an invented-panel calibration +and the mutation runner) is in `docs/design/track_b_b0_2_evidence/`. + +Review round 3 (Sol, at `2d0ed992`) asked for changes, and the block was +revised before any freeze: +- **B0.1's counts:** B0.1 had already published exact counts of the same + persons, which the audit did not model. Two executed tables used them: + - with 97 invented B2 heads, B0.1's exact count of those aged 45-64, + against this release's 45-61 band published as 100 (95 to 104), left + the 62-64 band one or two; + - a five-head table, both bands under 10, left the 62-64 band one to + four. + + B0.1's sets are now rebuilt in memory by its own rules and enter the + audit at their exact counts, after a rerun of B0.1's script on the same + inputs must reproduce its record. This required an exception to the + rule against counting dispositions: B0.1's disposition sets are among + them. Nothing derived from them beyond B0.1's own counts is written. +- **An emptied pool:** disclosures could rule out every pool point, + leaving nothing to classify against. An empty pool now makes its + target narrow, the seed programs run again under the disclosures, and + the release is withheld if they find nothing. + +After round 3, the coordinator ruled that B0.1's sets may constrain the +audit on conditions, which the block now states (Disclosure rule, +"B0.1's sets: the conditions on their use"): a rerun gate, a write +boundary over every output path, a decision record, a stated limit, an +invariant test and no precedent. Meeting them changed the script before +any freeze: +- **The local copy** now holds only this release's counts and the + audit's decisions. It had also held each target's proof, programs and + range, the pool's size, the relation count and the times, which depend + on atoms that B0.1's sets refine. +- **Failures and warnings** print no value: a library's message, which + can quote a size, is dropped, and the solver prints nothing. +- **The rerun gate** checks each rebuilt set against its count before the + audit, where it had been checked only after registration. +- **Targets.** Two of this release's sets always make a target, and + their differences come first. Before, a difference of two of this + release's sets that both coincided with B0.1 sets was skipped, and a + difference involving a B0.1 set that held the same units could be + reached first, so which targets were this release's own depended on + B0.1's sets. + + +## Structural-audit protocol, version 1 (frozen before any count) + +### Scope + +Version 1 counts the structure of the staged PSID for four purposes: + +1. **H's candidate start rosters.** B4's coverage declaration cites H's + roster (B0.1 addendum, "To B0.2"). +2. **B3's support at ages 62-69.** These are B3's bands, design line 127. +3. **B3L's 2006-origin roster and its later support.** B3L is the + 2006→2022 backtest, design line 128. +4. **The PSID persons and households outside B2's evaluation units.** + J needs a holdout independent of B2's evaluation data (design lines + 365-368). + +It computes no power quantity. The power procedure waits for Max's +ruling on it. + +### What this audit may and may not compute + +**It may compute:** +- counts of persons, of households (interview numbers) and of survey + design strata and clusters; +- counts of valid head and spouse labor-income rows, and of same-person + valid-row pairs at fixed lags; +- Kish and cluster-worst effective sizes from weights; +- SHA-256 hashes of the files it opens. + +**It may not:** +- read or compute any labor-income level, sign, zero or positive + indicator, quantile or change; +- read any marital, marriage-history, death, disability, + employment-status, Social Security, wealth or other income variable; +- count dispositions that separate death from attrition, or any + transition. The one exception is B0.1's published sets, its disposition + sets among them. They are rebuilt in memory, by B0.1's own rules, only + to constrain the audit, under the conditions the Disclosure rule sets + out ("B0.1's sets: the conditions on their use"). Nothing derived from + them beyond B0.1's own counts and the audit's decisions is written or + printed anywhere; +- compute any planning value, power probability, model fit, projection, + floor, gap or candidate outcome. + +These rules apply the forbidden-disclosure list in landscape lane a5 +("What the structural-audit protocol should allow and forbid") to the +PSID. In particular, for B3 and B3L no earnings sign, positive-pair +count, participation, transition, crossing, quantile, cumulative-earnings +or persistence value is computed. For B4, no marital state, transition, +DI status, death, survival or widowhood frequency is computed. For J, no +joint state, recipient or payment value is computed. + +### Inputs + +1. **The staged PSID directory,** resolved by the repository's own rule + (`populace_dynamics.data.psid`). +2. **The cross-year individual file `IND2023ER`,** read through + `panels.ind_person_period` with exactly the concept labels B0.1's + counting script uses for age, sequence number, relationship to the + reference person, individual weight and interview number + (`scripts/track_b_b0_1_counts.py`, `_individual_variables`). It covers + collection waves 1999 to 2023, every second year, plus 2007, 2009 and + 2011 where those are not already included. +3. **The 2011 anchor-wave variables,** read through + `cohorts.psid2010.read_anchor_wave(anchor_wave=2011)`: interview, + sequence, relationship, reported age and the 2011 cross-sectional + weight. Its `reported_birth_year` column is dropped as soon as it is + read. +4. **The survey design variables,** read through + `cohorts.age67.read_design_variables`: sampling error stratum + (ER31996) and cluster (ER31997). +5. **The family files for collection waves 1999 to 2023,** read through + `family.read_family_labor` with its label verification, then reduced + at once by B0.1's `validity_frame` + (`scripts/track_b_b0_1_counts.py`), which is imported unchanged. + +6. **B0.1's committed counts,** `docs/design/track_b_b0_1_counts.json`. + They are public and exact, so they constrain the audit (Disclosure + rule). Before any of B0.1's sets enters the audit, the script reruns + B0.1's `structural_counts` (`scripts/track_b_b0_1_counts.py`) on its + own inputs and stops unless the result equals the committed counts + exactly and each rebuilt set's size is the count B0.1 published for + it. Each ledger count B0.1 copied must equal the B0.1 count it + repeats. + +No other file or variable is opened. The script records the label of +every variable it requests, and a test pins that list as an allowlist. + +### Reduction at read time + +Each family file's labor-income levels are replaced by one flag per +role, "below the PSID missing sentinel", using B0.1's `validity_frame`. +The accuracy codes are replaced by "positive". No later step sees a +level. + +A unit test replaces every valid level with an arbitrary valid level and +requires every output to stay byte-identical. A second unit test adds +arbitrary extra columns of a forbidden kind to the input frames (marital +status, death year, Social Security amount) and requires every output to +stay byte-identical. + +### Definitions + +- **Collection wave and reference year.** Collection wave *w* collects + reference year *w* − 1. Ages are collection-wave ages, as in B2 + (`src/populace_dynamics/track_b/b2.py`, `cohort`). The B3 and B3L + records may choose another age convention; version 1 counts at + collection-wave age and says so. +- **Valid row.** At wave *w*, a person present (sequence 1-20) who is the + head or reference person, or the wife, spouse or partner, under that + wave's relationship codes, with a positive weight at *w*, and whose + role's labor income is below the sentinel. This is B0.1's + `earnings_rows` rule, imported unchanged. +- **Known age.** An age is known when it is an integer from 1 to 125, + the range the repository's readers accept + (`cohorts.age67._MAX_AGE_CODE`, which treats larger values, such as + 999, as missing). Any other value, including 0 and 999, is an unknown + age. A person of unknown age is in no age band and in no age-defined + roster. +- **Age bands,** on known ages only. + - Older bands: 62-64, 65-66, 67-69 (B3's bands). + - Context bands: 25-44, 45-61. + - Roster bands: 1-24, 25-44, 45-61, 62-64, 65-66, 67-69, 70-79, 80 and + over. +- **Rosters at the 2011 wave** (H's candidates): + - `R_FULL`: persons present (sequence 1-20) with a positive individual + weight at 2011. This is B0.1's full anchor. + - `R_B2`: B2's domain, `R_FULL` persons with a valid 2010-reference row + (`b2.domain_person_ids`). The script asserts that its size equals + B0.1's committed count (`docs/design/track_b_b0_1_counts.json`). + B0.1's counts also constrain the audit (Inputs, item 6). + - `R_TA_PROXY`: persons present at 2011 with a positive 2011 + cross-sectional weight and a known 2011 age of 30 or over. + - It is a proxy for Track A's closed cohort (persons born in 1980 or + earlier, `cohorts/psid2010.py`). + - Track A's own birth-year law reads marriage histories and earnings, + so version 1 does not reproduce it. The H record cites Track A's + own record for the exact cohort. +- **Roster counts.** For each roster, overall, by roster band at 2011, + and for unknown age at 2011, so that the bands and unknown age + partition the roster: + - persons; + - households (2011 interview numbers); + - design strata and clusters; + - Kish effective size; + - cluster-worst effective size, with households as clusters, and again + with design clusters as clusters. + + Weights are the roster's own weight: individual weight for `R_FULL` + and `R_B2`, the cross-sectional weight for `R_TA_PROXY`. +- **B3 support.** For each wave *w* from 1999 to 2023 and each older + band: + - valid rows, persons and households; + - design strata and clusters; + - Kish and cluster-worst effective sizes, using the wave's individual + weight; + - the number of those rows whose accuracy code is positive (a + data-quality count). + + Across waves: distinct persons per older band. +- **B3 lag pairs.** For lags of 2 and 4 years, and for each pair of waves + (*w*, *w* + lag) inside 1999-2023, count persons with a valid row at + both waves, by older band at the later wave. These are structural + support for transition and persistence estimands; no earnings value + enters. +- **B3L roster and later support.** + - `R2006_FULL`: persons present with a positive individual weight at + the 2007 wave. + - `D2006`: those `R2006_FULL` persons with a valid row at 2007 (income + year 2006). + - Roster counts for both, as above, by roster band at 2007. + - Then, for each later wave *w* from 2009 to 2023: the number of + `D2006` persons with a valid row at *w*, by band at collection-wave + age (context and older bands), and the weighted Kish size of those + rows using `D2006`'s 2007 weight. + - No disposition (death, move-out, nonresponse) is counted. +- **Outside B2's evaluation units.** + - B2's evaluation units are the `R_B2` persons and every 2011 household + containing one. + - Count the `R_FULL` persons outside those households, overall and by + roster band: persons, households, design strata and clusters, and + Kish and cluster-worst effective sizes. + - Separately, at waves 2013 and 2015, count the households (wave + interview numbers) containing at least one `R_B2` person. These + count households, not outcomes. +- **Effective sizes.** Kish size (Σw)² / Σw². Cluster-worst size + (Σw)² / Σ_c W_c², where W_c is cluster *c*'s total weight. These are + B0.1's `kish_n_eff` and `cluster_worst_n_eff`, imported unchanged. A + set with no positive weight reports 0. + +### Disclosure rule + +PSID outputs are aggregate statistics only, and no small cell is +committed, per the project's data-access record. Suppressing small +cells, and their complements by subtraction, is not enough: integer and +nonnegativity bounds, and remainders that are never published, can still +pin a small cell exactly (review round 1 of this protocol). Auditing the +published values alone is not enough either: anything else the committed +file says about the audit, including whether it passed, is evidence a +reader can use (review round 2). So the committed output is coarsened at +one fixed base, then audited against everything it discloses, by these +rules. + +- **Coarsening at base B = 10.** + - A count of 0 is written as 0. + - A count from 1 to B − 1 is written as the string `"