File size: 6,477 Bytes
3fb95e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | #!/usr/bin/env python3
"""Design the prospective formula benchmark (in-silico, preregistered).
Generates 30-50 deliberately-designed formulas from a restricted palette of
common hobbyist-available aroma chemicals (option held open for a future
community physical-compounding arm). Each formula spans a genre and a target
family profile with a top/heart/base substantivity structure.
Blinding: formulas and their *intended* target profiles are stored separately.
- data/benchmarks/prospective_formulas/formulas.jsonl : formula + structure (shareable)
- data/benchmarks/prospective_formulas/labels.sequestered.json : intended targets (EVAL ONLY)
The labels file carries access:"evaluation_only" and must not enter training or
model-selection inputs.
Deterministic: --seed fixes all sampling so the set is reproducible.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
from pathlib import Path
GENRES = {
"citrus_cologne": ["citrus_fresh", "aromatic_herbal", "green"],
"floral_woody": ["floral_sweet", "woody_amber", "musk_clean"],
"amber_oriental": ["gourmand", "woody_amber", "musk_clean"],
"fougere": ["aromatic_herbal", "green", "woody_amber"],
"wildcard": None, # any combination
}
# Substantivity bands (log10 hours-ish predicted) used to slot top/heart/base.
def band(sub):
if sub is None:
return "heart"
if sub < 1.0:
return "top"
if sub < 1.9:
return "heart"
return "base"
def load_palette(path: Path) -> dict[str, dict]:
return {k: v for k, v in json.loads(path.read_text()).items()}
def pick(seq, rng, n):
seq = list(seq)
rng.shuffle(seq)
return seq[:n]
def build_formula(fid, genre, palette, rng):
fams = GENRES[genre]
if fams is None:
fams = rng.sample(
["citrus_fresh", "floral_sweet", "woody_amber", "gourmand", "aromatic_herbal", "musk_clean", "green"],
k=rng.choice([2, 3]),
)
# Pool of materials across the chosen families, slotted by substantivity band.
by_band = {"top": [], "heart": [], "base": []}
for name, meta in palette.items():
if meta["family"] in fams:
by_band[band(meta.get("substantivity_log10"))].append((name, meta))
n_ing = rng.randint(6, 10)
n_top = max(1, round(n_ing * 0.3))
n_base = max(1, round(n_ing * 0.3))
n_heart = n_ing - n_top - n_base
chosen = []
chosen += pick(by_band["top"], rng, min(n_top, len(by_band["top"])))
chosen += pick(by_band["heart"], rng, min(n_heart, len(by_band["heart"])))
chosen += pick(by_band["base"], rng, min(n_base, len(by_band["base"])))
# Top up if a band ran dry.
if len(chosen) < n_ing:
rest = [(n, m) for b in by_band.values() for (n, m) in b if n not in {c[0] for c in chosen}]
chosen += pick(rest, rng, min(n_ing - len(chosen), len(rest)))
chosen = chosen[:n_ing]
# Dosing: base notes heavier, top notes lighter, with jitter; then normalise.
raw = []
for name, meta in chosen:
b = band(meta.get("substantivity_log10"))
w = {"top": rng.uniform(0.5, 2.0), "heart": rng.uniform(1.0, 4.0), "base": rng.uniform(2.0, 8.0)}[b]
raw.append((name, meta, w))
total = sum(w for _, _, w in raw)
ingredients = [
{
"name": meta["name"],
"cas": meta.get("cas"),
"smiles": meta.get("smiles"),
"family": meta["family"],
"weight_fraction": round(w / total, 4),
}
for (name, meta, w) in raw
]
# Intended target: weight-averaged family profile (the sequestered label).
fam_target: dict[str, float] = {}
for ing in ingredients:
fam_target[ing["family"]] = fam_target.get(ing["family"], 0.0) + ing["weight_fraction"]
fam_target = {k: round(v, 4) for k, v in sorted(fam_target.items())}
formula = {
"formula_id": fid,
"genre": genre,
"intended_families": fams,
"ingredients": ingredients,
"n_ingredients": len(ingredients),
"design": "in_silico_preregistered",
}
label = {
"formula_id": fid,
"target_family_profile": fam_target,
"access": "evaluation_only",
}
return formula, label
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--palette", default="artifacts/prospective/common_palette.json")
ap.add_argument("--out-dir", default="data/benchmarks/prospective_formulas")
ap.add_argument("--n", type=int, default=40)
ap.add_argument("--seed", type=int, default=20260717)
args = ap.parse_args()
palette = load_palette(Path(args.palette))
rng = random.Random(args.seed)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
genres = list(GENRES)
formulas, labels = [], []
for i in range(args.n):
genre = genres[i % len(genres)]
fid = f"PROSP_{args.seed}_{i:03d}"
f, l = build_formula(fid, genre, palette, rng)
formulas.append(f)
labels.append(l)
fpath = out_dir / "formulas.jsonl"
with fpath.open("w") as fh:
for f in formulas:
fh.write(json.dumps(f) + "\n")
lpath = out_dir / "labels.sequestered.json"
lpath.write_text(json.dumps({
"access": "evaluation_only",
"note": "Intended target family profiles. Forbidden in training and model selection.",
"seed": args.seed,
"labels": labels,
}, indent=1))
manifest = {
"benchmark": "prospective_formulas",
"created": "2026-07-17",
"seed": args.seed,
"n_formulas": len(formulas),
"genres": {g: sum(1 for f in formulas if f["genre"] == g) for g in genres},
"palette_size": len(palette),
"evaluation": "in_silico (model scoring/retrieval vs sequestered intended profiles)",
"community_arm": "option held open; palette restricted to hobbyist-available materials",
"label_access": "evaluation_only; forbidden in training and model selection",
"formula_sha256": hashlib.sha256(fpath.read_bytes()).hexdigest(),
"label_sha256": hashlib.sha256(lpath.read_bytes()).hexdigest(),
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=1))
print(f"wrote {len(formulas)} formulas -> {fpath}")
print(f"sequestered labels -> {lpath}")
print("genre counts:", manifest["genres"])
if __name__ == "__main__":
main()
|