Skip to content

Commit 00c2fcd

Browse files
cschanhniemOpenCode
andauthored
feat: Phase F F5 safe-publication filter -- SPF- schema gates negative-result external release with dual-use/privacy checks and controlled redaction vocabulary (#1020)
Co-authored-by: OpenCode <opencode@example.com>
1 parent 9fb8f7b commit 00c2fcd

4 files changed

Lines changed: 557 additions & 449 deletions

File tree

‎docs/research/NEXT_100_PR_MAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Make failure useful.
113113
| F2 | Add negative-result archive summary schema (complete). | Prevents incomplete archives: indexes NRR- entries, enforces completeness_confirmed and all_results_have_reason before batch is marked archived. | B |
114114
| F3 | Add rejection reason entry schema (complete). | Enables analysis of failed candidates with controlled vocabulary of stages and reasons; feeds calibration loop with structured failure signal. | B |
115115
| F4 | Add failed candidate batch report schema (complete). | Makes failed batches reviewable: links RJR- rejection reasons and NAS- archive into a validated batch-level report with failure rate consistency check. | B/C |
116-
| F5 | Add safe-publication filter for negative results. | Supports openness without unsafe release. | D |
116+
| F5 | Add safe-publication filter for negative results. | Supports openness without unsafe release. | D | DONE |
117117
| F6 | Add examples of non-informative vs informative negative results (complete). — docs/evidence/NEGATIVE_RESULT_EXAMPLES.md: 5 worked examples (hemolysis rejection, module timeout, dual-use flag, ad-hoc removal, low-confidence borderline); summary table; agent MUST rules. | Better interpretation. | A |
118118
| F7 | Add calibration link from negative-result entries to intake reports (complete). — evidence/negative_result_calibration_link.py: links NRR- rejection records to calibration intake reports for closed-loop learning; tests/evidence/test_negative_result_calibration_link.py. | Closes learning loop. | C |
119119
| F8 | Add benchmark for whether rejected candidates resemble known failure modes (complete). — FMS- schema: 14 fields, 11 validation rules, PATTERN_REPEATED_THRESHOLD=0.80, pattern_repeated_flag enforcement, calibration_action_recommended; 63 tests. | Improves rejection logic. | C |
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""SPF- safe-publication filter for negative results.
2+
3+
Determines whether a negative-result record is safe to publish externally,
4+
requires redaction before publication, or must be blocked entirely.
5+
6+
Negative results are scientifically valuable — showing what failed and why
7+
improves reproducibility and prevents others from repeating dead ends. But
8+
they can also contain sensitive content: dual-use sequences, proprietary
9+
candidate identifiers, or privacy-sensitive data from collaboration partners.
10+
11+
This filter makes the publication decision machine-checkable rather than
12+
relying on ad-hoc human judgment. Every negative result intended for public
13+
release must pass through an SPF- record.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from dataclasses import dataclass, field
19+
20+
VALID_SPF_VERDICTS: frozenset[str] = frozenset({
21+
"safe_to_publish",
22+
"requires_redaction",
23+
"publication_blocked",
24+
})
25+
26+
VALID_SPF_BLOCK_REASONS: frozenset[str] = frozenset({
27+
"dual_use_concern",
28+
"sequence_privacy_violation",
29+
"proprietary_candidate_id_present",
30+
"collaborator_identity_exposed",
31+
"preliminary_wet_lab_data_present",
32+
"regulatory_restriction",
33+
})
34+
35+
VALID_REDACTION_TYPES: frozenset[str] = frozenset({
36+
"candidate_id_anonymised",
37+
"sequence_truncated",
38+
"collaborator_name_removed",
39+
"batch_id_removed",
40+
"score_precision_reduced",
41+
})
42+
43+
44+
@dataclass
45+
class SafePublicationFilter:
46+
spf_id: str
47+
nrr_id: str
48+
pipeline_version: str
49+
publication_verdict: str
50+
dual_use_clear: bool
51+
sequence_privacy_clear: bool
52+
informative_as_negative: bool
53+
blocked_reasons: list[str]
54+
redactions_applied: list[str]
55+
dry_lab_only: bool
56+
limitations: list[str]
57+
created_at: str
58+
59+
60+
def validate_safe_publication_filter(spf: SafePublicationFilter) -> None:
61+
if not spf.spf_id.startswith("SPF-"):
62+
raise ValueError(f"spf_id must start with 'SPF-': {spf.spf_id!r}")
63+
if not spf.nrr_id.startswith("NRR-"):
64+
raise ValueError(f"nrr_id must start with 'NRR-': {spf.nrr_id!r}")
65+
if not spf.pipeline_version:
66+
raise ValueError("pipeline_version must be non-empty")
67+
if spf.publication_verdict not in VALID_SPF_VERDICTS:
68+
raise ValueError(
69+
f"publication_verdict {spf.publication_verdict!r} not in VALID_SPF_VERDICTS"
70+
)
71+
for reason in spf.blocked_reasons:
72+
if reason not in VALID_SPF_BLOCK_REASONS:
73+
raise ValueError(
74+
f"block reason {reason!r} not in VALID_SPF_BLOCK_REASONS"
75+
)
76+
for redaction in spf.redactions_applied:
77+
if redaction not in VALID_REDACTION_TYPES:
78+
raise ValueError(
79+
f"redaction type {redaction!r} not in VALID_REDACTION_TYPES"
80+
)
81+
if spf.publication_verdict == "publication_blocked" and not spf.blocked_reasons:
82+
raise ValueError(
83+
"blocked_reasons must be non-empty when publication_verdict='publication_blocked'"
84+
)
85+
if spf.publication_verdict == "requires_redaction" and not spf.redactions_applied:
86+
raise ValueError(
87+
"redactions_applied must be non-empty when publication_verdict='requires_redaction'"
88+
)
89+
if spf.publication_verdict == "safe_to_publish":
90+
if not spf.dual_use_clear:
91+
raise ValueError(
92+
"dual_use_clear must be True when publication_verdict='safe_to_publish'"
93+
)
94+
if not spf.sequence_privacy_clear:
95+
raise ValueError(
96+
"sequence_privacy_clear must be True when publication_verdict='safe_to_publish'"
97+
)
98+
if not spf.dual_use_clear and spf.publication_verdict != "publication_blocked":
99+
raise ValueError(
100+
"dual_use_clear=False requires publication_verdict='publication_blocked'"
101+
)
102+
if not spf.dry_lab_only:
103+
raise ValueError("dry_lab_only must be True")
104+
if not spf.limitations:
105+
raise ValueError("limitations must be non-empty")
106+
if not spf.created_at:
107+
raise ValueError("created_at must be non-empty")
108+
109+
110+
def _compute_verdict(
111+
dual_use_clear: bool,
112+
sequence_privacy_clear: bool,
113+
has_block_reasons: bool,
114+
has_redactions: bool,
115+
) -> str:
116+
if not dual_use_clear or has_block_reasons:
117+
return "publication_blocked"
118+
if not sequence_privacy_clear or has_redactions:
119+
return "requires_redaction"
120+
return "safe_to_publish"
121+
122+
123+
def build_safe_publication_filter(
124+
*,
125+
spf_id: str,
126+
nrr_id: str,
127+
pipeline_version: str,
128+
dual_use_clear: bool,
129+
sequence_privacy_clear: bool,
130+
informative_as_negative: bool = True,
131+
blocked_reasons: list[str] | None = None,
132+
redactions_applied: list[str] | None = None,
133+
limitations: list[str],
134+
created_at: str,
135+
) -> SafePublicationFilter:
136+
"""Build a SafePublicationFilter.
137+
138+
publication_verdict is auto-computed from dual_use_clear,
139+
sequence_privacy_clear, blocked_reasons, and redactions_applied.
140+
"""
141+
blocked_reasons = list(blocked_reasons) if blocked_reasons else []
142+
redactions_applied = list(redactions_applied) if redactions_applied else []
143+
verdict = _compute_verdict(
144+
dual_use_clear=dual_use_clear,
145+
sequence_privacy_clear=sequence_privacy_clear,
146+
has_block_reasons=bool(blocked_reasons),
147+
has_redactions=bool(redactions_applied),
148+
)
149+
spf = SafePublicationFilter(
150+
spf_id=spf_id,
151+
nrr_id=nrr_id,
152+
pipeline_version=pipeline_version,
153+
publication_verdict=verdict,
154+
dual_use_clear=dual_use_clear,
155+
sequence_privacy_clear=sequence_privacy_clear,
156+
informative_as_negative=informative_as_negative,
157+
blocked_reasons=blocked_reasons,
158+
redactions_applied=redactions_applied,
159+
dry_lab_only=True,
160+
limitations=limitations,
161+
created_at=created_at,
162+
)
163+
validate_safe_publication_filter(spf)
164+
return spf
165+
166+
167+
def format_safe_publication_filter(spf: SafePublicationFilter) -> str:
168+
lines = [
169+
f"Safe-Publication Filter — {spf.spf_id}",
170+
f"NRR: {spf.nrr_id} | Pipeline: {spf.pipeline_version}",
171+
f"Verdict: {spf.publication_verdict}",
172+
f"Dual-use clear: {spf.dual_use_clear}",
173+
f"Sequence privacy clear: {spf.sequence_privacy_clear}",
174+
f"Informative as negative: {spf.informative_as_negative}",
175+
]
176+
if spf.blocked_reasons:
177+
lines.append(f"Blocked reasons: {'; '.join(spf.blocked_reasons)}")
178+
if spf.redactions_applied:
179+
lines.append(f"Redactions applied: {'; '.join(spf.redactions_applied)}")
180+
lines.append(f"Limitations: {'; '.join(spf.limitations)}")
181+
lines.append(f"Created: {spf.created_at}")
182+
lines.append(f"dry_lab_only: {spf.dry_lab_only}")
183+
return "\n".join(lines)

0 commit comments

Comments
 (0)