Skip to content

Commit 82c8725

Browse files
committed
[fix.dirac.σ.D9+] preserve one Program Work Item and identity chain because route continuity without shared Job and material persistence was a false end-to-end
The browser handoff, Program aggregate, compound-to-sample contract, migration, tests, and regenerated architecture twin are one causal slice: browser rehearsal exposed that Program and Job could use different databases and that route changes replaced work context. This rejects a client-only navigation patch; canonical identity and database ownership are repaired first, then projected into all eight workspaces.
1 parent e240f28 commit 82c8725

28 files changed

Lines changed: 10257 additions & 5847 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
BEGIN;
2+
3+
ALTER TABLE design.program_event DROP CONSTRAINT program_event_event_kind_check;
4+
ALTER TABLE design.program_event ADD CONSTRAINT program_event_event_kind_check CHECK (event_kind IN (
5+
'program.created','program.updated','objective.recorded','hypothesis.recorded',
6+
'decision.recorded','milestone.recorded','object.linked','snapshot.created',
7+
'portfolio.assigned','member.assigned','stage_gate.recorded','work_package.recorded',
8+
'work_item.transitioned','work_execution.linked','evidence.attached','lineage.recorded',
9+
'target_disease.linked','substance_registration.recorded','batch.registered',
10+
'sample.created','sample.transferred','work_comment.recorded','work_attachment.recorded',
11+
'gate_criterion.assessed','protocol.recorded','dataset_version.committed','experiment.recorded',
12+
'structure_observation.recorded','annotation.recorded','review.recorded',
13+
'analysis_snapshot.created','external_evidence_release.imported','external_evidence.recorded'
14+
));
15+
16+
INSERT INTO meta.migration (filename,sha256,content_sha256,hash_source)
17+
VALUES ('036_program_batch_registration.sql',
18+
'\x51d1d3ebeca316012b092cfa37f401482b92d8cc392ca74b0e412181f0582779'::bytea,
19+
'\x51d1d3ebeca316012b092cfa37f401482b92d8cc392ca74b0e412181f0582779'::bytea,
20+
'content')
21+
ON CONFLICT (filename) DO NOTHING;
22+
23+
COMMIT;

‎backend/dirac_app/handlers.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,52 @@ def program_link(input: dict, ctx) -> dict:
385385
input["role"], input.get("rationale"), ctx.actor, ctx.request_id)
386386

387387

388+
def compound_register(input: dict, ctx) -> dict:
389+
"""Standardize a designed molecule once, then link that canonical Compound."""
390+
import rdkit
391+
from rdkit import Chem
392+
from rdkit.Chem import Descriptors, rdMolDescriptors
393+
from rdkit.Chem.MolStandardize import rdMolStandardize
394+
395+
raw = Chem.MolFromSmiles(input["smiles"])
396+
if raw is None:
397+
raise failures.DiracParseFailure("cannot parse designed molecule SMILES")
398+
try:
399+
parent = rdMolStandardize.Cleanup(raw)
400+
parent = rdMolStandardize.FragmentParent(parent)
401+
parent = rdMolStandardize.Uncharger().uncharge(parent)
402+
parent = rdMolStandardize.TautomerEnumerator().Canonicalize(parent)
403+
Chem.AssignStereochemistry(parent, cleanIt=True, force=True)
404+
except Exception as error: # noqa: BLE001 - convert toolkit failure to contract error
405+
raise failures.DiracInvalidParameters(
406+
"molecule standardization failed", details={"reason": str(error)}) from error
407+
centers = Chem.FindMolChiralCenters(
408+
parent, includeUnassigned=True, useLegacyImplementation=False)
409+
unassigned = sum(1 for _, tag in centers if tag == "?")
410+
stereo = ("no_stereocenters" if not centers else "fully_defined" if not unassigned
411+
else "undefined" if unassigned == len(centers) else "partially_defined")
412+
inchikey = Chem.MolToInchiKey(parent)
413+
inchi = Chem.MolToInchi(parent)
414+
if not inchikey or not inchi:
415+
raise failures.DiracParseFailure("standardized molecule has no InChI identity")
416+
compound = {
417+
"inchikey": inchikey, "inchi": inchi,
418+
"smiles": Chem.MolToSmiles(parent, canonical=True),
419+
"formula": rdMolDescriptors.CalcMolFormula(parent),
420+
"mw_monoisotopic": Descriptors.ExactMolWt(parent),
421+
"net_charge": Chem.GetFormalCharge(parent), "stereo": stereo,
422+
"standardizer": {"label": "dirac-parent-v1", "toolkit": "rdkit",
423+
"version": rdkit.__version__,
424+
"rules": ["Cleanup", "FragmentParent", "Uncharger",
425+
"TautomerCanonicalize", "AssignStereochemistry"]},
426+
"is_virtual": True,
427+
}
428+
return _programs(ctx).register_compound(
429+
input["program_ref"], input["expected_version"], compound,
430+
input.get("role", "design-candidate"), input.get("rationale"),
431+
ctx.actor, ctx.request_id)
432+
433+
388434
def program_snapshot_create(input: dict, ctx) -> dict:
389435
return _programs(ctx).create_snapshot(
390436
input["program_ref"], input["expected_version"], ctx.actor, ctx.request_id)
@@ -393,6 +439,7 @@ def program_snapshot_create(input: dict, ctx) -> dict:
393439
_REFERENCE_JOB_BY_COMMAND = {
394440
"program.target_disease.link": "target_disease",
395441
"identity.substance_registration.record": "substance_registration",
442+
"material.batch.register": "batch",
396443
"sample.create": "sample",
397444
"sample.transfer": "sample_transfer",
398445
"program.work_comment.record": "work_comment",

‎backend/field_server.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,11 @@ def _handle_admin(path: str):
179179
# not its ground state is a singlet.
180180
COORDINATION_METAL_Z = (set(range(21, 31)) | set(range(39, 49))
181181
| set(range(57, 81)) | set(range(89, 104)))
182-
DB_DSN = 'dbname=dirac user=ivan'
182+
# One process must not split canonical Programs from the Job ledger and cache.
183+
# The former already honored DIRAC_DSN through kernel.py; this route-local seam
184+
# used to stay pinned to the main database, making a Job visible in Compute but
185+
# impossible to attach to a Program served from an isolated/alternate database.
186+
DB_DSN = os.environ.get('DIRAC_DSN', 'dbname=dirac user=ivan')
183187
CUBE_MEDIA_TYPE = 'chemical/x-gaussian-cube'
184188
# Bound on POST bodies: the daemon binds 0.0.0.0 now, and an unbounded read
185189
# lets any LAN peer feed it a multi-GB body (peer session's hardening, ported).

‎backend/programs/domain.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,16 @@
4848
("sample", "has_measurement", "measurement"),
4949
})
5050
REFERENCE_JOB_KINDS = frozenset({
51-
"target_disease", "substance_registration", "sample", "sample_transfer",
51+
"target_disease", "substance_registration", "batch", "sample", "sample_transfer",
5252
"work_comment", "work_attachment", "gate_criterion", "protocol_version",
5353
"dataset_version", "experiment", "structure_observation", "annotation",
5454
"review", "analysis_snapshot", "evidence_release", "external_evidence",
5555
})
5656
REFERENCE_JOB_FIELDS = {
5757
"target_disease": frozenset({"disease_key", "name", "description", "ontology", "target_ref", "role", "rationale"}),
5858
"substance_registration": frozenset({"compound_ref", "status", "definition", "validation", "decision"}),
59+
"batch": frozenset({"compound_ref", "batch_code", "form_kind", "provenance", "purity_pct",
60+
"purity_method", "amount_mg", "supplier", "synthesized_on", "label"}),
5961
"sample": frozenset({"sample_code", "batch_ref", "parent_sample_ref", "amount_value", "amount_unit", "container", "location"}),
6062
"sample_transfer": frozenset({"sample_ref", "to_location", "reason"}),
6163
"work_comment": frozenset({"work_item_ref", "body"}),
@@ -364,6 +366,31 @@ def reference_job(kind: str, value: dict[str, Any]) -> dict[str, Any]:
364366
return {"compound_ref": ref(value.get("compound_ref"), "compound"), "status": status,
365367
"definition": _object(value.get("definition"), "definition"),
366368
"validation": _object(value.get("validation", {}), "validation"), "decision": decision}
369+
if kind == "batch":
370+
form_kind = _choice(value.get("form_kind", "neutral"),
371+
{"neutral", "free_base", "free_acid", "salt", "hydrate", "solvate", "cocrystal", "mixture"},
372+
"form_kind")
373+
provenance = _choice(value.get("provenance", "internal_synthesis"),
374+
{"internal_synthesis", "purchase", "gift", "literature_only", "virtual"}, "provenance")
375+
purity = value.get("purity_pct")
376+
purity_method = value.get("purity_method")
377+
if (purity is None) != (purity_method is None):
378+
raise failures.DiracInvalidParameters("purity_pct and purity_method must be recorded together")
379+
if purity is not None:
380+
purity = _positive_number(purity, "purity_pct")
381+
if purity > 100:
382+
raise failures.DiracInvalidParameters("purity_pct cannot exceed 100")
383+
purity_method = _choice(purity_method,
384+
{"hplc_uv", "lcms", "nmr", "elemental", "qnmr", "supplier_coa"}, "purity_method")
385+
amount = value.get("amount_mg")
386+
return {"compound_ref": ref(value.get("compound_ref"), "compound"),
387+
"batch_code": key(value.get("batch_code"), "batch_code").upper(),
388+
"form_kind": form_kind, "provenance": provenance,
389+
"purity_pct": purity, "purity_method": purity_method,
390+
"amount_mg": _positive_number(amount, "amount_mg", allow_zero=True) if amount is not None else None,
391+
"supplier": _optional_text(value.get("supplier"), 256),
392+
"synthesized_on": _iso_date(value.get("synthesized_on"), "synthesized_on"),
393+
"label": _optional_text(value.get("label"), 256)}
367394
if kind == "sample":
368395
amount = _positive_number(value.get("amount_value"), "amount_value", allow_zero=True)
369396
unit = str(value.get("amount_unit", ""))

0 commit comments

Comments
 (0)