Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c904cfa
Track B B0.2: plan (d1802)
MaxGhenis Oct 21, 2026
42ac50f
B0.2 plan: name the working folder and the lane root
MaxGhenis Oct 21, 2026
e1c3c1e
B0.2: start the held-out data registry for Track B and open-populatio…
MaxGhenis Oct 21, 2026
530ac69
B0.2 plan: the held-out data registry moves to its own PR (#560)
MaxGhenis Oct 21, 2026
207f408
B0.2: landscape synthesis of the five lanes
MaxGhenis Oct 23, 2026
b6a4031
B0.2 landscape: BEPUF 2020 is fully synthetic, so it cannot be an obs…
MaxGhenis Oct 23, 2026
8225ae8
B0.2: structural-audit protocol, version 1 (draft, not frozen)
MaxGhenis Oct 23, 2026
b491f4b
B0.2 landscape: the restricted linked-data decision, which cannot wai…
MaxGhenis Oct 23, 2026
6e6deb9
B0.2 protocol v1 draft: add the small-cell disclosure rule (d1899)
MaxGhenis Oct 23, 2026
be7d4f4
B0.2: the structural count script, with the small-cell disclosure rule
MaxGhenis Oct 23, 2026
ec9eb48
B0.2 protocol: the freeze is an explicit status commit after review, …
MaxGhenis Oct 23, 2026
7165a80
B0.2: a standing exclusion record, so successor review roots never ho…
MaxGhenis Oct 23, 2026
f5d22c1
B0.2 protocol v1 (draft): answer review round 1 in the block
MaxGhenis Oct 23, 2026
f7aece8
B0.2 plan: the exposure inventory covers every gate with a committed …
MaxGhenis Oct 23, 2026
3f5fa9f
B0.2 count script: implement the revised protocol block (review round 1)
MaxGhenis Oct 23, 2026
0644b71
B0.2: the README tier table matches the manifest (unit 10,040)
MaxGhenis Oct 23, 2026
22fcd5b
B0.2 protocol and count script: no audit program sees a true size (re…
MaxGhenis Oct 24, 2026
4b14a4a
B0.2 plan and landscape: public HRS available; the projection principle
MaxGhenis Oct 24, 2026
045d746
Merge origin/master (f8ffc7d9) into trackb-b0-2-20261021; recount tiers
MaxGhenis Oct 24, 2026
88211bf
B0.2: state the width rule exactly; add the audit's evidence scripts
MaxGhenis Oct 24, 2026
bfd439c
B0.2 tests: target order, timed-out searches, unguaranteed relations
MaxGhenis Oct 24, 2026
2d0ed99
B0.2 evidence: outputs, the mutation record at bfd439cf, report section
MaxGhenis Oct 24, 2026
2402c02
B0.2: B0.1's counts constrain the audit; an emptied pool reseeds
MaxGhenis Oct 24, 2026
2e4dba0
B0.2: the local copy writes nothing of B0.1's sets beyond their counts
MaxGhenis Oct 24, 2026
e2d7535
B0.2 protocol: answer review round 3 in the block; repin
MaxGhenis Oct 24, 2026
c84f898
B0.2 mutation specs: 22 checks of round 3's lines
MaxGhenis Oct 24, 2026
9a0ef5f
B0.2: the conditions on B0.1's sets, in the block and the script
MaxGhenis Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,027 changes: 1,027 additions & 0 deletions docs/design/track_b_b0_2_counts_mutations.json

Large diffs are not rendered by default.

262 changes: 262 additions & 0 deletions docs/design/track_b_b0_2_counts_report.txt

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions docs/design/track_b_b0_2_evidence/calibrate.py
Original file line number Diff line number Diff line change
@@ -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:],
)
51 changes: 51 additions & 0 deletions docs/design/track_b_b0_2_evidence/calibration.txt
Original file line number Diff line number Diff line change
@@ -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')]
62 changes: 62 additions & 0 deletions docs/design/track_b_b0_2_evidence/exhaust_closure.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions docs/design/track_b_b0_2_evidence/exhaust_closure.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
structures 96 tables 263424 multi 0
136 changes: 136 additions & 0 deletions docs/design/track_b_b0_2_evidence/invented_panel.py
Original file line number Diff line number Diff line change
@@ -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
Loading