@moleculeagora/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/index.js +30368 -0
- package/dist/index.js.map +1 -0
- package/dist/python-v1/agora_runtime.py +282 -0
- package/dist/python-v1/answer-set-metric.py +264 -0
- package/dist/python-v1/assertion-set-evaluation.py +879 -0
- package/dist/python-v1/exact-match.py +60 -0
- package/dist/python-v1/l4-composition.py +435 -0
- package/dist/python-v1/multi-output-tabular-metric.py +392 -0
- package/dist/python-v1/panel-ranking-metric.py +622 -0
- package/dist/python-v1/project-test.py +256 -0
- package/dist/python-v1/protein-binder-assay-metric.py +600 -0
- package/dist/python-v1/public-tool-metric.py +161 -0
- package/dist/python-v1/ranking-metric.py +426 -0
- package/dist/python-v1/reference-artifact-assertion.py +532 -0
- package/dist/python-v1/rubric-validation.py +246 -0
- package/dist/python-v1/solver-python-stdio-test.py +160 -0
- package/dist/python-v1/statistical-endpoint-test-v2.py +629 -0
- package/dist/python-v1/statistical-endpoint-test.py +442 -0
- package/dist/python-v1/table-metric.py +1291 -0
- package/dist/release-metadata.json +7 -0
- package/package.json +67 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
from agora_runtime import (
|
|
5
|
+
fail_runtime,
|
|
6
|
+
load_json_file,
|
|
7
|
+
load_runtime_context,
|
|
8
|
+
reject_submission,
|
|
9
|
+
resolve_evaluation_artifact,
|
|
10
|
+
resolve_scoring_asset,
|
|
11
|
+
resolve_submission_artifact,
|
|
12
|
+
write_score,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
REQUIRED_SUBMISSION_COLUMNS = ("design_id", "sequence")
|
|
16
|
+
REQUIRED_ASSAY_COLUMNS = (
|
|
17
|
+
"design_id",
|
|
18
|
+
"metric",
|
|
19
|
+
"target",
|
|
20
|
+
"value_type",
|
|
21
|
+
"value",
|
|
22
|
+
"unit",
|
|
23
|
+
)
|
|
24
|
+
OPTIONAL_SUBMISSION_COLUMNS = ("rank", "track", "method_id")
|
|
25
|
+
SUPPORTED_REPLICATE_POLICIES = ("median", "geometric_mean")
|
|
26
|
+
SUPPORTED_QUALIFIERS = ("exact", "not_detected", "greater_than", "less_than")
|
|
27
|
+
AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTVWY")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_string(value, label, invalid_handler=fail_runtime):
|
|
31
|
+
if not isinstance(value, str) or not value.strip():
|
|
32
|
+
invalid_handler(f"{label} must be a non-empty string.")
|
|
33
|
+
return value.strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_token(value, label, invalid_handler=fail_runtime):
|
|
37
|
+
return require_string(value, label, invalid_handler).strip().lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_config_dict(config, key):
|
|
41
|
+
value = config.get(key)
|
|
42
|
+
if not isinstance(value, dict):
|
|
43
|
+
fail_runtime(f"compiled_config.{key} must be an object.")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_literal(value, expected, label):
|
|
48
|
+
if value != expected:
|
|
49
|
+
fail_runtime(f"{label} must be {expected!r}.")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def require_replicate_policy(value, label):
|
|
54
|
+
policy = normalize_token(value, label)
|
|
55
|
+
if policy not in SUPPORTED_REPLICATE_POLICIES:
|
|
56
|
+
fail_runtime(
|
|
57
|
+
f"{label} must be one of {', '.join(SUPPORTED_REPLICATE_POLICIES)}."
|
|
58
|
+
)
|
|
59
|
+
return policy
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_non_binding_policy(value):
|
|
63
|
+
if not isinstance(value, dict):
|
|
64
|
+
fail_runtime("assay_metric.non_binding_policy must be an object.")
|
|
65
|
+
require_literal(
|
|
66
|
+
value.get("kind"),
|
|
67
|
+
"fixed_score",
|
|
68
|
+
"assay_metric.non_binding_policy.kind",
|
|
69
|
+
)
|
|
70
|
+
score = value.get("score")
|
|
71
|
+
if isinstance(score, bool) or not isinstance(score, (int, float)):
|
|
72
|
+
fail_runtime("assay_metric.non_binding_policy.score must be a finite number.")
|
|
73
|
+
score = float(score)
|
|
74
|
+
if not math.isfinite(score) or score < 0.0:
|
|
75
|
+
fail_runtime(
|
|
76
|
+
"assay_metric.non_binding_policy.score must be finite and nonnegative."
|
|
77
|
+
)
|
|
78
|
+
return {"kind": "fixed_score", "score": score}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize_gates(value):
|
|
82
|
+
if not isinstance(value, list):
|
|
83
|
+
fail_runtime("assay_metric.gates must be an array.")
|
|
84
|
+
gates = []
|
|
85
|
+
for index, gate in enumerate(value):
|
|
86
|
+
if not isinstance(gate, dict):
|
|
87
|
+
fail_runtime(f"assay_metric.gates[{index}] must be an object.")
|
|
88
|
+
metric = normalize_token(gate.get("metric"), f"assay_metric.gates[{index}].metric")
|
|
89
|
+
target = gate.get("target")
|
|
90
|
+
normalized_target = (
|
|
91
|
+
normalize_token(target, f"assay_metric.gates[{index}].target")
|
|
92
|
+
if target is not None
|
|
93
|
+
else None
|
|
94
|
+
)
|
|
95
|
+
value_type = normalize_token(
|
|
96
|
+
gate.get("value_type"),
|
|
97
|
+
f"assay_metric.gates[{index}].value_type",
|
|
98
|
+
)
|
|
99
|
+
if value_type == "boolean":
|
|
100
|
+
if not isinstance(gate.get("pass"), bool):
|
|
101
|
+
fail_runtime(f"assay_metric.gates[{index}].pass must be boolean.")
|
|
102
|
+
gates.append(
|
|
103
|
+
{
|
|
104
|
+
"metric": metric,
|
|
105
|
+
"target": normalized_target,
|
|
106
|
+
"value_type": "boolean",
|
|
107
|
+
"pass": gate["pass"],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
if value_type == "label":
|
|
112
|
+
disallow = gate.get("disallow")
|
|
113
|
+
if not isinstance(disallow, list) or not disallow:
|
|
114
|
+
fail_runtime(
|
|
115
|
+
f"assay_metric.gates[{index}].disallow must be a non-empty array."
|
|
116
|
+
)
|
|
117
|
+
labels = set()
|
|
118
|
+
for label_index, raw_label in enumerate(disallow):
|
|
119
|
+
labels.add(
|
|
120
|
+
normalize_token(
|
|
121
|
+
raw_label,
|
|
122
|
+
f"assay_metric.gates[{index}].disallow[{label_index}]",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
gates.append(
|
|
126
|
+
{
|
|
127
|
+
"metric": metric,
|
|
128
|
+
"target": normalized_target,
|
|
129
|
+
"value_type": "label",
|
|
130
|
+
"disallow": labels,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
fail_runtime(
|
|
135
|
+
f"assay_metric.gates[{index}].value_type must be boolean or label."
|
|
136
|
+
)
|
|
137
|
+
return gates
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_assay_metric_config(config):
|
|
141
|
+
assay_metric = require_config_dict(config, "assay_metric")
|
|
142
|
+
primary_target = normalize_token(
|
|
143
|
+
assay_metric.get("primary_target"),
|
|
144
|
+
"assay_metric.primary_target",
|
|
145
|
+
)
|
|
146
|
+
require_literal(assay_metric.get("primary_metric"), "kd", "assay_metric.primary_metric")
|
|
147
|
+
require_literal(assay_metric.get("kd_unit"), "M", "assay_metric.kd_unit")
|
|
148
|
+
require_literal(
|
|
149
|
+
assay_metric.get("score_transform"),
|
|
150
|
+
"negative_log10_molar",
|
|
151
|
+
"assay_metric.score_transform",
|
|
152
|
+
)
|
|
153
|
+
return {
|
|
154
|
+
"primary_target": primary_target,
|
|
155
|
+
"primary_metric": "kd",
|
|
156
|
+
"kd_unit": "M",
|
|
157
|
+
"score_transform": "negative_log10_molar",
|
|
158
|
+
"replicate_policy": require_replicate_policy(
|
|
159
|
+
assay_metric.get("replicate_policy"),
|
|
160
|
+
"assay_metric.replicate_policy",
|
|
161
|
+
),
|
|
162
|
+
"non_binding_policy": require_non_binding_policy(
|
|
163
|
+
assay_metric.get("non_binding_policy")
|
|
164
|
+
),
|
|
165
|
+
"gates": normalize_gates(assay_metric.get("gates")),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def require_csv_slot(runtime_context, lane, role):
|
|
170
|
+
artifact_contract = runtime_context.get("artifact_contract")
|
|
171
|
+
if not isinstance(artifact_contract, dict):
|
|
172
|
+
fail_runtime("Runtime context is missing artifact_contract.")
|
|
173
|
+
slots = artifact_contract.get(lane)
|
|
174
|
+
if not isinstance(slots, list):
|
|
175
|
+
fail_runtime(f"Runtime context is missing artifact_contract.{lane}.")
|
|
176
|
+
for slot in slots:
|
|
177
|
+
if not isinstance(slot, dict) or slot.get("role") != role:
|
|
178
|
+
continue
|
|
179
|
+
validator = slot.get("validator")
|
|
180
|
+
if not isinstance(validator, dict) or validator.get("kind") != "csv_columns":
|
|
181
|
+
fail_runtime(
|
|
182
|
+
f"{lane} role {role} must use validator.kind=csv_columns for protein_binder_assay_metric."
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
fail_runtime(f"Runtime context is missing {lane} slot for role {role}.")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_csv_rows(path, label, invalid_handler):
|
|
189
|
+
try:
|
|
190
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
191
|
+
reader = csv.DictReader(handle)
|
|
192
|
+
fieldnames = reader.fieldnames
|
|
193
|
+
if not fieldnames:
|
|
194
|
+
invalid_handler(f"{label} must include a CSV header row.")
|
|
195
|
+
normalized = []
|
|
196
|
+
seen = set()
|
|
197
|
+
for fieldname in fieldnames:
|
|
198
|
+
if not isinstance(fieldname, str) or not fieldname.strip():
|
|
199
|
+
invalid_handler(f"{label} contains an empty CSV column name.")
|
|
200
|
+
column = fieldname.strip()
|
|
201
|
+
if column in seen:
|
|
202
|
+
invalid_handler(f"{label} contains duplicate CSV column {column!r}.")
|
|
203
|
+
seen.add(column)
|
|
204
|
+
normalized.append((fieldname, column))
|
|
205
|
+
rows = []
|
|
206
|
+
for row_index, row in enumerate(reader, start=2):
|
|
207
|
+
if None in row:
|
|
208
|
+
invalid_handler(f"{label} row {row_index} has too many columns.")
|
|
209
|
+
rows.append(
|
|
210
|
+
{
|
|
211
|
+
normalized_name: row.get(raw_name, "")
|
|
212
|
+
for raw_name, normalized_name in normalized
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
invalid_handler(f"Missing {label} at {path}.")
|
|
217
|
+
except OSError as error:
|
|
218
|
+
invalid_handler(f"Unable to read {label}: {error}.")
|
|
219
|
+
return [column for _, column in normalized], rows
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def require_columns(fieldnames, required_columns, label, invalid_handler):
|
|
223
|
+
missing = [column for column in required_columns if column not in fieldnames]
|
|
224
|
+
if missing:
|
|
225
|
+
invalid_handler(f"{label} is missing required columns: {', '.join(missing)}.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_rank(raw_value, label):
|
|
229
|
+
if raw_value is None or not str(raw_value).strip():
|
|
230
|
+
return None
|
|
231
|
+
text = str(raw_value).strip()
|
|
232
|
+
try:
|
|
233
|
+
rank = float(text)
|
|
234
|
+
except ValueError:
|
|
235
|
+
reject_submission(f"{label} must be numeric when present.")
|
|
236
|
+
if not math.isfinite(rank) or rank <= 0.0:
|
|
237
|
+
reject_submission(f"{label} must be finite and positive when present.")
|
|
238
|
+
return rank
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def normalize_sequence(raw_value, label):
|
|
242
|
+
sequence = require_string(raw_value, label, reject_submission).upper()
|
|
243
|
+
invalid = sorted(set(sequence) - AMINO_ACIDS)
|
|
244
|
+
if invalid:
|
|
245
|
+
reject_submission(
|
|
246
|
+
f"{label} contains unsupported amino acid letters {''.join(invalid)!r}; use canonical one-letter protein residues only."
|
|
247
|
+
)
|
|
248
|
+
return sequence
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_candidate_designs(path, role):
|
|
252
|
+
fieldnames, rows = read_csv_rows(
|
|
253
|
+
path,
|
|
254
|
+
f"submission artifact {role}",
|
|
255
|
+
reject_submission,
|
|
256
|
+
)
|
|
257
|
+
require_columns(
|
|
258
|
+
fieldnames,
|
|
259
|
+
REQUIRED_SUBMISSION_COLUMNS,
|
|
260
|
+
f"submission artifact {role}",
|
|
261
|
+
reject_submission,
|
|
262
|
+
)
|
|
263
|
+
allowed_columns = set(REQUIRED_SUBMISSION_COLUMNS) | set(OPTIONAL_SUBMISSION_COLUMNS)
|
|
264
|
+
for column in fieldnames:
|
|
265
|
+
if column not in allowed_columns:
|
|
266
|
+
continue
|
|
267
|
+
candidates = {}
|
|
268
|
+
for row_index, row in enumerate(rows, start=2):
|
|
269
|
+
design_id = require_string(
|
|
270
|
+
row.get("design_id"),
|
|
271
|
+
f"submission artifact {role} row {row_index} design_id",
|
|
272
|
+
reject_submission,
|
|
273
|
+
)
|
|
274
|
+
if design_id in candidates:
|
|
275
|
+
reject_submission(
|
|
276
|
+
f"submission artifact {role} contains duplicate design_id {design_id!r}."
|
|
277
|
+
)
|
|
278
|
+
candidates[design_id] = {
|
|
279
|
+
"design_id": design_id,
|
|
280
|
+
"sequence": normalize_sequence(
|
|
281
|
+
row.get("sequence"),
|
|
282
|
+
f"submission artifact {role} row {row_index} sequence",
|
|
283
|
+
),
|
|
284
|
+
"rank": parse_rank(
|
|
285
|
+
row.get("rank"),
|
|
286
|
+
f"submission artifact {role} row {row_index} rank",
|
|
287
|
+
)
|
|
288
|
+
if "rank" in fieldnames
|
|
289
|
+
else None,
|
|
290
|
+
}
|
|
291
|
+
if not candidates:
|
|
292
|
+
reject_submission(f"submission artifact {role} must contain at least one design.")
|
|
293
|
+
return candidates
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def normalize_qualifier(raw_value, label):
|
|
297
|
+
text = ""
|
|
298
|
+
if raw_value is not None:
|
|
299
|
+
text = str(raw_value).strip().lower()
|
|
300
|
+
qualifier = text or "exact"
|
|
301
|
+
if qualifier not in SUPPORTED_QUALIFIERS:
|
|
302
|
+
fail_runtime(
|
|
303
|
+
f"{label} qualifier must be one of {', '.join(SUPPORTED_QUALIFIERS)}."
|
|
304
|
+
)
|
|
305
|
+
return qualifier
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load_assay_rows(path, role):
|
|
309
|
+
fieldnames, rows = read_csv_rows(
|
|
310
|
+
path,
|
|
311
|
+
f"evaluation artifact {role}",
|
|
312
|
+
fail_runtime,
|
|
313
|
+
)
|
|
314
|
+
require_columns(
|
|
315
|
+
fieldnames,
|
|
316
|
+
REQUIRED_ASSAY_COLUMNS,
|
|
317
|
+
f"evaluation artifact {role}",
|
|
318
|
+
fail_runtime,
|
|
319
|
+
)
|
|
320
|
+
assay_by_design = {}
|
|
321
|
+
for row_index, row in enumerate(rows, start=2):
|
|
322
|
+
design_id = require_string(
|
|
323
|
+
row.get("design_id"),
|
|
324
|
+
f"evaluation artifact {role} row {row_index} design_id",
|
|
325
|
+
)
|
|
326
|
+
metric = normalize_token(
|
|
327
|
+
row.get("metric"),
|
|
328
|
+
f"evaluation artifact {role} row {row_index} metric",
|
|
329
|
+
)
|
|
330
|
+
target = normalize_token(
|
|
331
|
+
row.get("target"),
|
|
332
|
+
f"evaluation artifact {role} row {row_index} target",
|
|
333
|
+
)
|
|
334
|
+
value_type = normalize_token(
|
|
335
|
+
row.get("value_type"),
|
|
336
|
+
f"evaluation artifact {role} row {row_index} value_type",
|
|
337
|
+
)
|
|
338
|
+
assay_row = {
|
|
339
|
+
"row_index": row_index,
|
|
340
|
+
"design_id": design_id,
|
|
341
|
+
"metric": metric,
|
|
342
|
+
"target": target,
|
|
343
|
+
"value_type": value_type,
|
|
344
|
+
"value": row.get("value", ""),
|
|
345
|
+
"unit": str(row.get("unit", "")).strip(),
|
|
346
|
+
"qualifier": normalize_qualifier(
|
|
347
|
+
row.get("qualifier"),
|
|
348
|
+
f"evaluation artifact {role} row {row_index}",
|
|
349
|
+
)
|
|
350
|
+
if "qualifier" in fieldnames
|
|
351
|
+
else "exact",
|
|
352
|
+
}
|
|
353
|
+
assay_by_design.setdefault(design_id, []).append(assay_row)
|
|
354
|
+
if not rows:
|
|
355
|
+
fail_runtime(f"evaluation artifact {role} must contain at least one assay row.")
|
|
356
|
+
return assay_by_design
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def parse_boolean_value(raw_value, label):
|
|
360
|
+
value = normalize_token(raw_value, label)
|
|
361
|
+
if value == "true":
|
|
362
|
+
return True
|
|
363
|
+
if value == "false":
|
|
364
|
+
return False
|
|
365
|
+
fail_runtime(f"{label} must be true or false.")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_label_value(raw_value, label):
|
|
369
|
+
return normalize_token(raw_value, label)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def gate_matches(row, gate):
|
|
373
|
+
if row["metric"] != gate["metric"]:
|
|
374
|
+
return False
|
|
375
|
+
if row["value_type"] != gate["value_type"]:
|
|
376
|
+
return False
|
|
377
|
+
if gate["target"] is not None and row["target"] != gate["target"]:
|
|
378
|
+
return False
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def design_passes_gate(rows, gate):
|
|
383
|
+
matching = [row for row in rows if gate_matches(row, gate)]
|
|
384
|
+
if not matching:
|
|
385
|
+
return False
|
|
386
|
+
if gate["value_type"] == "boolean":
|
|
387
|
+
expected = gate["pass"]
|
|
388
|
+
for row in matching:
|
|
389
|
+
observed = parse_boolean_value(
|
|
390
|
+
row["value"],
|
|
391
|
+
f"evaluation assay row {row['row_index']} boolean gate value",
|
|
392
|
+
)
|
|
393
|
+
if observed != expected:
|
|
394
|
+
return False
|
|
395
|
+
return True
|
|
396
|
+
for row in matching:
|
|
397
|
+
observed = parse_label_value(
|
|
398
|
+
row["value"],
|
|
399
|
+
f"evaluation assay row {row['row_index']} label gate value",
|
|
400
|
+
)
|
|
401
|
+
if observed in gate["disallow"]:
|
|
402
|
+
return False
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def primary_row_matches(row, params):
|
|
407
|
+
return (
|
|
408
|
+
row["metric"] == params["primary_metric"]
|
|
409
|
+
and row["target"] == params["primary_target"]
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def parse_kd_value(raw_value, label):
|
|
414
|
+
text = str(raw_value).strip()
|
|
415
|
+
if not text:
|
|
416
|
+
fail_runtime(f"{label} must be present for exact KD rows.")
|
|
417
|
+
try:
|
|
418
|
+
value = float(text)
|
|
419
|
+
except ValueError:
|
|
420
|
+
fail_runtime(f"{label} must be numeric, received {text!r}.")
|
|
421
|
+
if not math.isfinite(value) or value <= 0.0:
|
|
422
|
+
fail_runtime(f"{label} must be finite and positive.")
|
|
423
|
+
return value
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def aggregate_exact_kd(values, policy):
|
|
427
|
+
ordered = sorted(values)
|
|
428
|
+
if policy == "median":
|
|
429
|
+
midpoint = len(ordered) // 2
|
|
430
|
+
if len(ordered) % 2 == 1:
|
|
431
|
+
return ordered[midpoint]
|
|
432
|
+
return (ordered[midpoint - 1] + ordered[midpoint]) / 2.0
|
|
433
|
+
log_sum = sum(math.log(value) for value in ordered)
|
|
434
|
+
return math.exp(log_sum / len(ordered))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def score_exact_kd(kd_m):
|
|
438
|
+
score = -math.log10(kd_m)
|
|
439
|
+
if not math.isfinite(score) or score < 0.0:
|
|
440
|
+
fail_runtime(
|
|
441
|
+
"negative_log10_molar produced a negative or non-finite score. Next step: provide KD values between 0 and 1 M for v1 scoring."
|
|
442
|
+
)
|
|
443
|
+
return score
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def score_design(candidate, rows, params):
|
|
447
|
+
if not all(design_passes_gate(rows, gate) for gate in params["gates"]):
|
|
448
|
+
return None
|
|
449
|
+
primary_rows = [row for row in rows if primary_row_matches(row, params)]
|
|
450
|
+
if not primary_rows:
|
|
451
|
+
return None
|
|
452
|
+
exact_values = []
|
|
453
|
+
has_not_detected = False
|
|
454
|
+
for row in primary_rows:
|
|
455
|
+
if row["unit"] != params["kd_unit"]:
|
|
456
|
+
fail_runtime(
|
|
457
|
+
f"evaluation assay row {row['row_index']} uses unit {row['unit']!r}; protein_binder_assay_metric@1 requires M."
|
|
458
|
+
)
|
|
459
|
+
if row["value_type"] != "number":
|
|
460
|
+
fail_runtime(
|
|
461
|
+
f"evaluation assay row {row['row_index']} primary KD value_type must be number."
|
|
462
|
+
)
|
|
463
|
+
qualifier = row["qualifier"]
|
|
464
|
+
if qualifier == "exact":
|
|
465
|
+
exact_values.append(
|
|
466
|
+
parse_kd_value(
|
|
467
|
+
row["value"],
|
|
468
|
+
f"evaluation assay row {row['row_index']} KD value",
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
continue
|
|
472
|
+
if qualifier == "not_detected":
|
|
473
|
+
has_not_detected = True
|
|
474
|
+
continue
|
|
475
|
+
fail_runtime(
|
|
476
|
+
f"evaluation assay row {row['row_index']} uses qualifier {qualifier}; v1 scores only exact KD values and not_detected rows."
|
|
477
|
+
)
|
|
478
|
+
if exact_values:
|
|
479
|
+
kd_m = aggregate_exact_kd(exact_values, params["replicate_policy"])
|
|
480
|
+
return {
|
|
481
|
+
"design_id": candidate["design_id"],
|
|
482
|
+
"rank": candidate["rank"],
|
|
483
|
+
"score": score_exact_kd(kd_m),
|
|
484
|
+
"best_kd_m": kd_m,
|
|
485
|
+
"assay_outcome": "exact",
|
|
486
|
+
"replicate_count": len(exact_values),
|
|
487
|
+
}
|
|
488
|
+
if has_not_detected:
|
|
489
|
+
return {
|
|
490
|
+
"design_id": candidate["design_id"],
|
|
491
|
+
"rank": candidate["rank"],
|
|
492
|
+
"score": params["non_binding_policy"]["score"],
|
|
493
|
+
"best_kd_m": 0.0,
|
|
494
|
+
"assay_outcome": "not_detected",
|
|
495
|
+
"replicate_count": 0,
|
|
496
|
+
}
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def choose_best_design(results):
|
|
501
|
+
return sorted(
|
|
502
|
+
results,
|
|
503
|
+
key=lambda item: (
|
|
504
|
+
-item["score"],
|
|
505
|
+
item["rank"] if item["rank"] is not None else math.inf,
|
|
506
|
+
item["design_id"],
|
|
507
|
+
),
|
|
508
|
+
)[0]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main():
|
|
512
|
+
runtime_context = load_runtime_context()
|
|
513
|
+
config_path = resolve_scoring_asset(
|
|
514
|
+
runtime_context,
|
|
515
|
+
"compiled_config",
|
|
516
|
+
kind="config",
|
|
517
|
+
)
|
|
518
|
+
try:
|
|
519
|
+
config = load_json_file(config_path, label="compiled_config")
|
|
520
|
+
except RuntimeError as error:
|
|
521
|
+
fail_runtime(str(error))
|
|
522
|
+
if not isinstance(config, dict):
|
|
523
|
+
fail_runtime("compiled_config must be a JSON object.")
|
|
524
|
+
|
|
525
|
+
assay_results_role = require_string(
|
|
526
|
+
config.get("assay_results_role"),
|
|
527
|
+
"compiled_config.assay_results_role",
|
|
528
|
+
)
|
|
529
|
+
candidate_designs_role = require_string(
|
|
530
|
+
config.get("candidate_designs_role"),
|
|
531
|
+
"compiled_config.candidate_designs_role",
|
|
532
|
+
)
|
|
533
|
+
final_score_key = require_string(
|
|
534
|
+
config.get("final_score_key"),
|
|
535
|
+
"compiled_config.final_score_key",
|
|
536
|
+
)
|
|
537
|
+
objective = require_string(
|
|
538
|
+
runtime_context.get("objective"),
|
|
539
|
+
"runtime_context.objective",
|
|
540
|
+
)
|
|
541
|
+
if objective != "maximize":
|
|
542
|
+
fail_runtime("protein_binder_assay_metric requires objective=maximize.")
|
|
543
|
+
|
|
544
|
+
params = load_assay_metric_config(config)
|
|
545
|
+
require_csv_slot(runtime_context, "evaluation", assay_results_role)
|
|
546
|
+
require_csv_slot(runtime_context, "submission", candidate_designs_role)
|
|
547
|
+
assay_path = resolve_evaluation_artifact(runtime_context, assay_results_role)
|
|
548
|
+
candidates_path = resolve_submission_artifact(
|
|
549
|
+
runtime_context,
|
|
550
|
+
candidate_designs_role,
|
|
551
|
+
)
|
|
552
|
+
candidates = load_candidate_designs(candidates_path, candidate_designs_role)
|
|
553
|
+
assay_by_design = load_assay_rows(assay_path, assay_results_role)
|
|
554
|
+
|
|
555
|
+
assay_matched_design_ids = set()
|
|
556
|
+
scored_results = []
|
|
557
|
+
for design_id, candidate in candidates.items():
|
|
558
|
+
rows = assay_by_design.get(design_id, [])
|
|
559
|
+
primary_rows = [row for row in rows if primary_row_matches(row, params)]
|
|
560
|
+
if primary_rows:
|
|
561
|
+
assay_matched_design_ids.add(design_id)
|
|
562
|
+
result = score_design(candidate, rows, params)
|
|
563
|
+
if result is not None:
|
|
564
|
+
scored_results.append(result)
|
|
565
|
+
|
|
566
|
+
if not assay_matched_design_ids:
|
|
567
|
+
reject_submission(
|
|
568
|
+
"Submission did not match any assay_results rows for the configured primary target and metric.",
|
|
569
|
+
details={
|
|
570
|
+
"valid_design_count": 0,
|
|
571
|
+
"assay_matched_design_count": 0,
|
|
572
|
+
},
|
|
573
|
+
)
|
|
574
|
+
if not scored_results:
|
|
575
|
+
reject_submission(
|
|
576
|
+
"No submitted design passed assay gates with scoreable KD evidence.",
|
|
577
|
+
details={
|
|
578
|
+
"valid_design_count": 0,
|
|
579
|
+
"assay_matched_design_count": len(assay_matched_design_ids),
|
|
580
|
+
},
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
best = choose_best_design(scored_results)
|
|
584
|
+
details = {
|
|
585
|
+
final_score_key: best["score"],
|
|
586
|
+
"score": best["score"],
|
|
587
|
+
"best_kd_m": best["best_kd_m"],
|
|
588
|
+
"valid_design_count": len(scored_results),
|
|
589
|
+
"assay_matched_design_count": len(assay_matched_design_ids),
|
|
590
|
+
"best_design_id": best["design_id"],
|
|
591
|
+
"best_assay_outcome": best["assay_outcome"],
|
|
592
|
+
"best_replicate_count": best["replicate_count"],
|
|
593
|
+
"replicate_policy": params["replicate_policy"],
|
|
594
|
+
"score_transform": params["score_transform"],
|
|
595
|
+
}
|
|
596
|
+
write_score(score=best["score"], details=details)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
main()
|