@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.
@@ -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()